Skip to content

[Bug]: device-key-ed25519.json rewritten non-atomically in ClearStoredTokens → torn write silently rotates device identity #888

Description

@KrasimirKralev

Summary

DeviceIdentityStore.ClearStoredTokens rewrites the authoritative device-identity file device-key-ed25519.json with a non-atomic File.WriteAllBytes. A crash, power-loss, or forced process-kill mid-write can leave a torn / zero-byte / truncated key file. On the next startup, DeviceIdentity.LoadExisting() treats an unparseable/empty file as invalid and silently regenerates a brand-new keypair — rotating the device identity and discarding the (non-regenerable) Ed25519 private key and any device tokens. The node then has to re-pair/re-enroll with the gateway.

This is the exact failure mode the codebase already guards against for this same file: the canonical writer DeviceIdentity.AtomicWriteKeyFile stages to a sibling temp file and File.Moves it into place specifically "so a process-kill or power-loss mid-write cannot leave a torn/zero-byte key file that the next LoadOrCreate would silently rotate the identity over." ClearStoredTokens is the one writer of this file that bypasses that protection.

Bug class: non-atomic persisted write of authoritative, non-regenerable security material (data-loss / auth).

Affected code

Non-atomic write of the identity file:

The recovery-less read path that turns a torn write into identity loss:

The canonical atomic writer for the very same file (the in-repo correct pattern):

Reachable, authoritative callers (this is not dead code):

  • src/OpenClaw.Connection/GatewayConnectionManager.cs:570 and :623 (token clear on disconnect / logout / role switch), plus ConnectionStatusWindow and ConnectionPage.

A second, related non-atomic write of the same file exists at src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs:2750 (File.WriteAllText(identityKeyPath, identityBackup) on the direct-connect rollback-restore path) — worth funneling through the same helper in one pass.

Steps to reproduce (analysis-derived, not captured on a live machine)

  1. Pair the Windows node so device-key-ed25519.json holds a keypair + an operator/node token.
  2. Trigger a token clear (disconnect / logout / switch gateway → GatewayConnectionManager calls ClearStoredTokens).
  3. Interrupt the process while File.WriteAllBytes at DeviceIdentityStore.cs:60 is mid-write (power-loss, OS kill, disk-full at the wrong moment). The file is left truncated/partial.
  4. Restart the app → DeviceIdentity.Initialize()LoadExisting() throws on the truncated JSON → GenerateNew().

Expected: clearing a token must never be able to destroy the keypair; an interrupted clear leaves the previous, intact identity file in place (atomic replace), so the device keeps its identity.

Actual (by code path): an interrupted clear can truncate the file; the next launch silently rotates the entire device identity, discarding the private key and all tokens. The device de-enrolls and must be re-paired/re-approved on the gateway.

Why this is a real defect, not intended behavior

  • The same file has a dedicated atomic writer with a comment naming this precise hazard (AtomicWriteKeyFile, DeviceIdentity.cs:514-520).
  • A behaviorally-identical sibling that clears tokens while preserving the keypair (TryClearDeviceTokenForRole) already does it atomically. ClearStoredTokens does the same job non-atomically — classic duplicate-implementation drift, not a deliberate design choice.
  • The blast radius is non-regenerable security material (Ed25519 private key + device tokens), and the read path silently rotates rather than surfacing an error, so the loss is invisible until pairing breaks.

(The torn-write window itself is timing-dependent and was reasoned from the source, not reproduced on hardware — flagged honestly as analysis, not an observed crash.)

Proposed fix options

  • Option A (recommended, smallest, matches in-repo pattern): make ClearStoredTokens write through the same atomic temp-file + File.Move(overwrite:true) + ACL-restrict path as DeviceIdentity.AtomicWriteKeyFile. Best done by reusing one helper rather than a third copy — e.g. expose the atomic writer (or a DeviceIdentity.ClearAllStoredTokens(dataPath) that nulls all four token fields and calls AtomicWriteKeyFile) and have ClearStoredTokens delegate to it. This also de-duplicates the two clear-token implementations.
  • Option B: keep ClearStoredTokens where it is but replace the raw File.WriteAllBytes(keyPath, …) with a local atomic temp+rename mirroring AtomicWriteKeyFile (preserve the file's existing ACL/permissions on the rename, per the existing helper's TryRestrictSensitiveFileAcl). Apply the same to the ConnectionPage.xaml.cs:2750 rollback-restore write.
  • Option C (defense-in-depth, complementary): in LoadExisting(), before GenerateNew() on a parse failure, distinguish "file present but unreadable/torn" from "file absent" and fail loud / preserve rather than silently rotating, so a corrupt identity is recoverable (e.g. back up the bad file and require explicit re-enroll) instead of silently destroyed.

Option A keeps the change Cold-shaped and consistent with the existing contract; C is a separate hardening that could ship independently.

What was / was not tested

  • Verified by source-tracing on main @ 87dc3dc: the non-atomic write site, the recovery-less LoadExisting → GenerateNew path, the atomic sibling that proves intent, and the live callers.
  • Not tested: no live Windows reproduction and no failing-then-passing unit test — File.Move atomicity is NTFS-specific and this is a Windows-targeted .NET app, which can't be exercised in a Linux sandbox. Filed as an investigated issue rather than a PR because converting a non-atomic write to temp+rename changes failure semantics on protected/permission-sensitive destinations (and this is auth material), so the maintainer should choose the storage/permission contract and add the Windows-side regression coverage.

Filed with AI assistance (automated source-pattern mining + analysis). Findings were source-confirmed against the cited code on main; the reproduction is analysis-derived, not captured on hardware. No fix has been pushed — happy for a maintainer to pick the contract in Option A/B/C.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority bug or improvement with limited blast radius.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerThis issue is about auth, provider routing, model choice, or SecretRef resolution.impact:data-lossThis issue is about lost, corrupted, or silently dropped user/session/config data.impact:securityThis issue is about security boundaries, credentials, authz, sandboxing, or sensitive data.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions