Skip to content

Commit 7463a34

Browse files
christineyan4Christine YanCopilot
authored
Fix atomic device-key token clearing (#889)
* Fix non-atomic device-key write in ClearStoredTokens ClearStoredTokens in DeviceIdentityStore used File.WriteAllBytes to rewrite device-key-ed25519.json. A crash/power-loss mid-write could leave a torn file, causing LoadExisting to silently regenerate a new keypair and rotate the device identity. Fix: Add DeviceIdentity.TryClearAllDeviceTokens that strips all token fields using raw JSON filtering and writes atomically via temp-file + File.Move (same pattern as AtomicWriteKeyFile). ClearStoredTokens now delegates to it. Also add AtomicWriteKeyFileRaw for the ConnectionPage rollback-restore path that previously used File.WriteAllText directly. Fixes #888 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle non-object identity JSON during token clear Guard TryClearAllDeviceTokens before enumerating JSON object properties so valid-but-non-object JSON remains a best-effort no-op instead of throwing through ClearStoredTokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve best-effort token clear behavior Keep DeviceIdentityStore.ClearStoredTokens as a best-effort operation by logging unexpected failures from the shared atomic token-clear helper instead of allowing them to escape connection cleanup flows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Christine Yan <christineyan@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 87dc3dc commit 7463a34

4 files changed

Lines changed: 98 additions & 22 deletions

File tree

src/OpenClaw.Connection/DeviceIdentityStore.cs

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,31 +34,14 @@ public void StoreToken(string identityPath, string token, string[]? scopes, stri
3434
/// Clear stored device tokens from an identity file, keeping the keypair intact.
3535
/// Strips DeviceToken, DeviceTokenScopes, NodeDeviceToken, and NodeDeviceTokenScopes
3636
/// from the identity JSON while preserving keys, deviceId, algorithm, etc.
37+
/// Writes atomically via temp-file + rename to prevent torn writes from
38+
/// silently rotating device identity on crash/power-loss.
3739
/// </summary>
3840
public static void ClearStoredTokens(string identityDir, IOpenClawLogger? logger = null)
3941
{
40-
var keyPath = Path.Combine(identityDir, "device-key-ed25519.json");
41-
if (!File.Exists(keyPath)) return;
4242
try
4343
{
44-
var json = File.ReadAllText(keyPath);
45-
var doc = System.Text.Json.JsonDocument.Parse(json);
46-
var root = doc.RootElement;
47-
48-
using var ms = new MemoryStream();
49-
using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
50-
writer.WriteStartObject();
51-
foreach (var prop in root.EnumerateObject())
52-
{
53-
if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes")
54-
continue;
55-
prop.WriteTo(writer);
56-
}
57-
writer.WriteEndObject();
58-
writer.Flush();
59-
60-
File.WriteAllBytes(keyPath, ms.ToArray());
61-
logger?.Info($"[IdentityStore] Cleared stored device tokens from {identityDir}");
44+
DeviceIdentity.TryClearAllDeviceTokens(identityDir, logger);
6245
}
6346
catch (Exception ex)
6447
{

src/OpenClaw.Shared/DeviceIdentity.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,77 @@ public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOp
9494
public static bool TryClearDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
9595
TryClearDeviceTokenForRole(dataPath, "operator", logger);
9696

97+
/// <summary>
98+
/// Atomically clears <em>all</em> device-token fields (DeviceToken,
99+
/// DeviceTokenScopes, NodeDeviceToken, NodeDeviceTokenScopes) from
100+
/// <c>device-key-ed25519.json</c> while preserving the Ed25519 keypair,
101+
/// deviceId, algorithm, and all other properties. Uses raw JSON filtering
102+
/// so unknown/extra fields are preserved, and writes atomically via
103+
/// temp-file + rename.
104+
/// </summary>
105+
/// <returns>
106+
/// <c>true</c> if at least one token field was present and cleared;
107+
/// <c>false</c> if the file was absent or already had no tokens.
108+
/// </returns>
109+
public static bool TryClearAllDeviceTokens(string dataPath, IOpenClawLogger? logger = null)
110+
{
111+
var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
112+
if (!File.Exists(keyPath))
113+
return false;
114+
115+
try
116+
{
117+
var json = File.ReadAllText(keyPath);
118+
using var doc = JsonDocument.Parse(json);
119+
var root = doc.RootElement;
120+
if (root.ValueKind != JsonValueKind.Object)
121+
{
122+
logger?.Warn("Failed to clear all device tokens: device-key-ed25519.json root is not a JSON object.");
123+
return false;
124+
}
125+
126+
bool hadTokens = false;
127+
using var ms = new MemoryStream();
128+
using (var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true }))
129+
{
130+
writer.WriteStartObject();
131+
foreach (var prop in root.EnumerateObject())
132+
{
133+
if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes")
134+
{
135+
hadTokens = true;
136+
continue;
137+
}
138+
prop.WriteTo(writer);
139+
}
140+
writer.WriteEndObject();
141+
}
142+
143+
if (!hadTokens)
144+
return false;
145+
146+
var content = System.Text.Encoding.UTF8.GetString(ms.ToArray());
147+
AtomicWriteKeyFileRaw(keyPath, content);
148+
logger?.Info("All device tokens cleared from device-key-ed25519.json (keypair preserved).");
149+
return true;
150+
}
151+
catch (IOException ex)
152+
{
153+
logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
154+
return false;
155+
}
156+
catch (UnauthorizedAccessException ex)
157+
{
158+
logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
159+
return false;
160+
}
161+
catch (JsonException ex)
162+
{
163+
logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
164+
return false;
165+
}
166+
}
167+
97168
/// <summary>
98169
/// Sets the role-specific device token field to <c>null</c> in
99170
/// <c>device-key-ed25519.json</c> without deleting the file. Preserves the
@@ -523,12 +594,22 @@ private void StoreNodeDeviceTokenCore(string token, string[]? scopes)
523594
private static void AtomicWriteKeyFile(string path, DeviceKeyData data)
524595
{
525596
var json = JsonSerializer.Serialize(data, JsonSerializerOptionsCache.WriteIndented);
597+
AtomicWriteKeyFileRaw(path, json);
598+
}
599+
600+
/// <summary>
601+
/// Atomically writes pre-serialized JSON content to a device-key file path
602+
/// using temp-file + rename. Use this when restoring a backup or writing
603+
/// content that is already serialized.
604+
/// </summary>
605+
public static void AtomicWriteKeyFileRaw(string path, string jsonContent)
606+
{
526607
var dir = Path.GetDirectoryName(path);
527608
var tempDir = string.IsNullOrEmpty(dir) ? Environment.CurrentDirectory : dir;
528609
var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
529610
try
530611
{
531-
File.WriteAllText(tempPath, json);
612+
File.WriteAllText(tempPath, jsonContent);
532613
McpAuthToken.TryRestrictSensitiveFileAcl(tempPath);
533614
File.Move(tempPath, path, overwrite: true);
534615
}

src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2747,7 +2747,7 @@ private void RollbackDirectConnect(
27472747
fileUnchanged = true;
27482748
}
27492749
if (fileUnchanged)
2750-
File.WriteAllText(identityKeyPath, identityBackup);
2750+
DeviceIdentity.AtomicWriteKeyFileRaw(identityKeyPath, identityBackup);
27512751
// else: another writer touched the file; preserve it.
27522752
}
27532753
catch (Exception ex)

tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ public void ClearStoredTokens_WhenFileAbsent_DoesNotThrow()
111111
Assert.Null(ex);
112112
}
113113

114+
[Fact]
115+
public void ClearStoredTokens_WhenJsonRootIsNotObject_DoesNotThrow()
116+
{
117+
var path = Path.Combine(_tempDir, "device-key-ed25519.json");
118+
File.WriteAllText(path, "[]");
119+
120+
var ex = Record.Exception(() => DeviceIdentityStore.ClearStoredTokens(_tempDir));
121+
122+
Assert.Null(ex);
123+
Assert.Equal("[]", File.ReadAllText(path));
124+
}
125+
114126
[Fact]
115127
public void ClearStoredTokens_WhenNoTokenFields_PreservesAllProperties()
116128
{

0 commit comments

Comments
 (0)