diff --git a/src/OpenClaw.Connection/DeviceIdentityStore.cs b/src/OpenClaw.Connection/DeviceIdentityStore.cs index 1c491e8bf..9c80e731b 100644 --- a/src/OpenClaw.Connection/DeviceIdentityStore.cs +++ b/src/OpenClaw.Connection/DeviceIdentityStore.cs @@ -34,31 +34,14 @@ public void StoreToken(string identityPath, string token, string[]? scopes, stri /// Clear stored device tokens from an identity file, keeping the keypair intact. /// Strips DeviceToken, DeviceTokenScopes, NodeDeviceToken, and NodeDeviceTokenScopes /// from the identity JSON while preserving keys, deviceId, algorithm, etc. + /// Writes atomically via temp-file + rename to prevent torn writes from + /// silently rotating device identity on crash/power-loss. /// public static void ClearStoredTokens(string identityDir, IOpenClawLogger? logger = null) { - var keyPath = Path.Combine(identityDir, "device-key-ed25519.json"); - if (!File.Exists(keyPath)) return; try { - var json = File.ReadAllText(keyPath); - var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - using var ms = new MemoryStream(); - using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true }); - writer.WriteStartObject(); - foreach (var prop in root.EnumerateObject()) - { - if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes") - continue; - prop.WriteTo(writer); - } - writer.WriteEndObject(); - writer.Flush(); - - File.WriteAllBytes(keyPath, ms.ToArray()); - logger?.Info($"[IdentityStore] Cleared stored device tokens from {identityDir}"); + DeviceIdentity.TryClearAllDeviceTokens(identityDir, logger); } catch (Exception ex) { diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index 3d66b4d75..bcb24cd7a 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -94,6 +94,77 @@ public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOp public static bool TryClearDeviceToken(string dataPath, IOpenClawLogger? logger = null) => TryClearDeviceTokenForRole(dataPath, "operator", logger); + /// + /// Atomically clears all device-token fields (DeviceToken, + /// DeviceTokenScopes, NodeDeviceToken, NodeDeviceTokenScopes) from + /// device-key-ed25519.json while preserving the Ed25519 keypair, + /// deviceId, algorithm, and all other properties. Uses raw JSON filtering + /// so unknown/extra fields are preserved, and writes atomically via + /// temp-file + rename. + /// + /// + /// true if at least one token field was present and cleared; + /// false if the file was absent or already had no tokens. + /// + public static bool TryClearAllDeviceTokens(string dataPath, IOpenClawLogger? logger = null) + { + var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); + if (!File.Exists(keyPath)) + return false; + + try + { + var json = File.ReadAllText(keyPath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + logger?.Warn("Failed to clear all device tokens: device-key-ed25519.json root is not a JSON object."); + return false; + } + + bool hadTokens = false; + using var ms = new MemoryStream(); + using (var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes") + { + hadTokens = true; + continue; + } + prop.WriteTo(writer); + } + writer.WriteEndObject(); + } + + if (!hadTokens) + return false; + + var content = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + AtomicWriteKeyFileRaw(keyPath, content); + logger?.Info("All device tokens cleared from device-key-ed25519.json (keypair preserved)."); + return true; + } + catch (IOException ex) + { + logger?.Warn($"Failed to clear all device tokens: {ex.Message}"); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.Warn($"Failed to clear all device tokens: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + logger?.Warn($"Failed to clear all device tokens: {ex.Message}"); + return false; + } + } + /// /// Sets the role-specific device token field to null in /// device-key-ed25519.json without deleting the file. Preserves the @@ -523,12 +594,22 @@ private void StoreNodeDeviceTokenCore(string token, string[]? scopes) private static void AtomicWriteKeyFile(string path, DeviceKeyData data) { var json = JsonSerializer.Serialize(data, JsonSerializerOptionsCache.WriteIndented); + AtomicWriteKeyFileRaw(path, json); + } + + /// + /// Atomically writes pre-serialized JSON content to a device-key file path + /// using temp-file + rename. Use this when restoring a backup or writing + /// content that is already serialized. + /// + public static void AtomicWriteKeyFileRaw(string path, string jsonContent) + { var dir = Path.GetDirectoryName(path); var tempDir = string.IsNullOrEmpty(dir) ? Environment.CurrentDirectory : dir; var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); try { - File.WriteAllText(tempPath, json); + File.WriteAllText(tempPath, jsonContent); McpAuthToken.TryRestrictSensitiveFileAcl(tempPath); File.Move(tempPath, path, overwrite: true); } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index dc5fb8ce2..0498ad20e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2747,7 +2747,7 @@ private void RollbackDirectConnect( fileUnchanged = true; } if (fileUnchanged) - File.WriteAllText(identityKeyPath, identityBackup); + DeviceIdentity.AtomicWriteKeyFileRaw(identityKeyPath, identityBackup); // else: another writer touched the file; preserve it. } catch (Exception ex) diff --git a/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs b/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs index e17f9c1f2..94b45bbc7 100644 --- a/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs +++ b/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs @@ -111,6 +111,18 @@ public void ClearStoredTokens_WhenFileAbsent_DoesNotThrow() Assert.Null(ex); } + [Fact] + public void ClearStoredTokens_WhenJsonRootIsNotObject_DoesNotThrow() + { + var path = Path.Combine(_tempDir, "device-key-ed25519.json"); + File.WriteAllText(path, "[]"); + + var ex = Record.Exception(() => DeviceIdentityStore.ClearStoredTokens(_tempDir)); + + Assert.Null(ex); + Assert.Equal("[]", File.ReadAllText(path)); + } + [Fact] public void ClearStoredTokens_WhenNoTokenFields_PreservesAllProperties() {