Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 3 additions & 20 deletions src/OpenClaw.Connection/DeviceIdentityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
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)
{
Expand Down
83 changes: 82 additions & 1 deletion src/OpenClaw.Shared/DeviceIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Atomically clears <em>all</em> device-token fields (DeviceToken,
/// DeviceTokenScopes, NodeDeviceToken, NodeDeviceTokenScopes) from
/// <c>device-key-ed25519.json</c> 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.
/// </summary>
/// <returns>
/// <c>true</c> if at least one token field was present and cleared;
/// <c>false</c> if the file was absent or already had no tokens.
/// </returns>
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;
}
}

/// <summary>
/// Sets the role-specific device token field to <c>null</c> in
/// <c>device-key-ed25519.json</c> without deleting the file. Preserves the
Expand Down Expand Up @@ -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);
}

/// <summary>
/// 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.
/// </summary>
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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down