using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using NSec.Cryptography; namespace Nexus.Api.Services; public sealed class OpenClawDeviceIdentity { private readonly byte[] _privateKeyBlob; internal OpenClawDeviceIdentity( string deviceId, string publicKey, byte[] privateKeyBlob) { DeviceId = deviceId; PublicKey = publicKey; _privateKeyBlob = privateKeyBlob.ToArray(); } public string DeviceId { get; } public string PublicKey { get; } public string Sign(string payload) { ArgumentNullException.ThrowIfNull(payload); using var key = Key.Import( SignatureAlgorithm.Ed25519, _privateKeyBlob, KeyBlobFormat.NSecPrivateKey); var signature = SignatureAlgorithm.Ed25519.Sign( key, Encoding.UTF8.GetBytes(payload)); return Base64UrlEncode(signature); } public bool Verify(string payload, string signature) { try { var publicKey = NSec.Cryptography.PublicKey.Import( SignatureAlgorithm.Ed25519, Base64UrlDecode(PublicKey), KeyBlobFormat.RawPublicKey); return SignatureAlgorithm.Ed25519.Verify( publicKey, Encoding.UTF8.GetBytes(payload), Base64UrlDecode(signature)); } catch (FormatException) { return false; } } internal byte[] ExportPrivateKeyBlob() => _privateKeyBlob.ToArray(); internal static string Base64UrlEncode(ReadOnlySpan value) => Convert.ToBase64String(value) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); internal static byte[] Base64UrlDecode(string value) { var normalized = value.Replace('-', '+').Replace('_', '/'); normalized = normalized.PadRight(normalized.Length + ((4 - normalized.Length % 4) % 4), '='); return Convert.FromBase64String(normalized); } } public sealed record OpenClawDeviceToken( string Token, IReadOnlyList Scopes, string Role, string GatewayBinding = ""); public interface IOpenClawDeviceIdentityStore { string StatePath { get; } Task LoadOrCreateAsync(CancellationToken cancellationToken = default); Task LoadTokenAsync( string deviceId, string role, CancellationToken cancellationToken = default); Task LoadTokenAsync( string deviceId, string role, string gatewayBinding, CancellationToken cancellationToken = default); Task StoreTokenAsync( string deviceId, string role, string token, IReadOnlyCollection scopes, CancellationToken cancellationToken = default); Task StoreTokenAsync( string deviceId, string role, string gatewayBinding, string token, IReadOnlyCollection scopes, CancellationToken cancellationToken = default); Task RemoveTokenAsync( string deviceId, string role, string gatewayBinding, CancellationToken cancellationToken = default); } /// /// Persists Nexus' backend-only OpenClaw device key and device token. The file /// is outside the repository by default, is written atomically, and is reduced /// to owner-only permissions on Unix. Windows uses the current user's private /// LocalApplicationData ACL inheritance unless an explicit path is configured. /// public sealed class OpenClawDeviceIdentityStore : IOpenClawDeviceIdentityStore { private const int CurrentSchemaVersion = 2; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); private readonly string _statePath; private DeviceState? _state; private OpenClawDeviceIdentity? _identity; public OpenClawDeviceIdentityStore( IOptions options, ILogger logger) { _logger = logger; _statePath = ResolveStatePath(options.Value.DeviceStatePath); } public string StatePath => _statePath; public async Task LoadOrCreateAsync( CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken); try { if (_identity is not null) return _identity; if (File.Exists(_statePath)) { EnsureRegularFile(_statePath); EnsureRestrictedPermissions(_statePath, isDirectory: false); _state = await ReadStateAsync(cancellationToken); _identity = ValidateAndCreateIdentity(_state); return _identity; } var directory = Path.GetDirectoryName(_statePath) ?? throw new InvalidOperationException("OpenClaw device state path has no parent directory."); Directory.CreateDirectory(directory); EnsureRestrictedPermissions(directory, isDirectory: true); using var key = Key.Create( SignatureAlgorithm.Ed25519, new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); var privateKey = key.Export(KeyBlobFormat.NSecPrivateKey); var publicKey = key.PublicKey.Export(KeyBlobFormat.RawPublicKey); var publicKeyEncoded = OpenClawDeviceIdentity.Base64UrlEncode(publicKey); var deviceId = Convert.ToHexStringLower(SHA256.HashData(publicKey)); _state = new DeviceState { SchemaVersion = CurrentSchemaVersion, DeviceId = deviceId, PublicKey = publicKeyEncoded, PrivateKey = OpenClawDeviceIdentity.Base64UrlEncode(privateKey), Tokens = [] }; await WriteStateAsync(_state, cancellationToken); _identity = new OpenClawDeviceIdentity(deviceId, publicKeyEncoded, privateKey); _logger.LogInformation( "Created persistent Nexus OpenClaw backend device identity {DeviceId} at {StatePath}", deviceId, _statePath); return _identity; } finally { _gate.Release(); } } public async Task LoadTokenAsync( string deviceId, string role, CancellationToken cancellationToken = default) => await LoadTokenAsync( deviceId, role, gatewayBinding: string.Empty, cancellationToken); public async Task LoadTokenAsync( string deviceId, string role, string gatewayBinding, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(role)) return null; await _gate.WaitAsync(cancellationToken); try { if (_state is null) { if (!File.Exists(_statePath)) return null; _state = await ReadStateAsync(cancellationToken); _identity = ValidateAndCreateIdentity(_state); } if (!string.Equals(_state.DeviceId, deviceId, StringComparison.Ordinal)) throw new InvalidOperationException("OpenClaw device state id does not match the active identity."); var token = _state.Tokens.FirstOrDefault(item => string.Equals(item.Role, role, StringComparison.Ordinal) && string.Equals(item.GatewayBinding, gatewayBinding, StringComparison.Ordinal)); return token is null || string.IsNullOrWhiteSpace(token.Token) ? null : new OpenClawDeviceToken( token.Token, token.Scopes .Where(scope => !string.IsNullOrWhiteSpace(scope)) .Distinct(StringComparer.Ordinal) .ToArray(), token.Role, token.GatewayBinding ?? string.Empty); } finally { _gate.Release(); } } public async Task StoreTokenAsync( string deviceId, string role, string token, IReadOnlyCollection scopes, CancellationToken cancellationToken = default) => await StoreTokenAsync( deviceId, role, gatewayBinding: string.Empty, token, scopes, cancellationToken); public async Task StoreTokenAsync( string deviceId, string role, string gatewayBinding, string token, IReadOnlyCollection scopes, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(token)) throw new ArgumentException("OpenClaw device token is required.", nameof(token)); await _gate.WaitAsync(cancellationToken); try { if (_state is null) { if (!File.Exists(_statePath)) throw new InvalidOperationException("OpenClaw device identity must exist before storing its token."); _state = await ReadStateAsync(cancellationToken); _identity = ValidateAndCreateIdentity(_state); } if (!string.Equals(_state.DeviceId, deviceId, StringComparison.Ordinal)) throw new InvalidOperationException("Refusing to store a token for a different OpenClaw device."); _state.Tokens.RemoveAll(item => string.Equals(item.Role, role, StringComparison.Ordinal) && string.Equals(item.GatewayBinding, gatewayBinding, StringComparison.Ordinal)); _state.Tokens.Add(new DeviceTokenState { Role = role, GatewayBinding = gatewayBinding, Token = token, Scopes = scopes .Where(scope => !string.IsNullOrWhiteSpace(scope)) .Distinct(StringComparer.Ordinal) .ToList() }); await WriteStateAsync(_state, cancellationToken); } finally { _gate.Release(); } } public async Task RemoveTokenAsync( string deviceId, string role, string gatewayBinding, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(role)) return false; await _gate.WaitAsync(cancellationToken); try { if (_state is null) { if (!File.Exists(_statePath)) return false; _state = await ReadStateAsync(cancellationToken); _identity = ValidateAndCreateIdentity(_state); } if (!string.Equals(_state.DeviceId, deviceId, StringComparison.Ordinal)) throw new InvalidOperationException("OpenClaw device state id does not match the active identity."); var removed = _state.Tokens.RemoveAll(item => string.Equals(item.Role, role, StringComparison.Ordinal) && string.Equals(item.GatewayBinding, gatewayBinding, StringComparison.Ordinal)); if (removed > 0) await WriteStateAsync(_state, cancellationToken); return removed > 0; } finally { _gate.Release(); } } public static string ResolveStatePath(string? configuredPath) { if (!string.IsNullOrWhiteSpace(configuredPath)) return Path.GetFullPath(configuredPath.Trim()); var localData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (string.IsNullOrWhiteSpace(localData)) localData = AppContext.BaseDirectory; return Path.Combine(localData, "Nexus", "openclaw", "device-state.json"); } private async Task ReadStateAsync(CancellationToken cancellationToken) { try { await using var stream = new FileStream( _statePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.SequentialScan); var state = await JsonSerializer.DeserializeAsync( stream, JsonOptions, cancellationToken); return state ?? throw new InvalidDataException("OpenClaw device state is empty."); } catch (Exception exception) when ( exception is JsonException or FormatException or InvalidDataException) { throw new InvalidDataException( $"OpenClaw device state at '{_statePath}' is invalid; refusing to rotate the identity silently.", exception); } } internal static void EnsureRegularFile(string path) { if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) { throw new InvalidDataException( $"OpenClaw device state at '{path}' must be a regular file, not a symbolic link or reparse point."); } } private static OpenClawDeviceIdentity ValidateAndCreateIdentity(DeviceState state) { if (state.SchemaVersion is not 1 and not CurrentSchemaVersion || string.IsNullOrWhiteSpace(state.DeviceId) || string.IsNullOrWhiteSpace(state.PublicKey) || string.IsNullOrWhiteSpace(state.PrivateKey)) { throw new InvalidDataException("OpenClaw device state is incomplete or has an unsupported schema."); } var privateKey = OpenClawDeviceIdentity.Base64UrlDecode(state.PrivateKey); using var key = Key.Import( SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.NSecPrivateKey); var publicKey = key.PublicKey.Export(KeyBlobFormat.RawPublicKey); var expectedPublicKey = OpenClawDeviceIdentity.Base64UrlEncode(publicKey); var expectedDeviceId = Convert.ToHexStringLower(SHA256.HashData(publicKey)); if (!CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expectedPublicKey), Encoding.ASCII.GetBytes(state.PublicKey)) || !CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expectedDeviceId), Encoding.ASCII.GetBytes(state.DeviceId))) { throw new InvalidDataException("OpenClaw device key, public key, and device id do not match."); } state.Tokens ??= []; if (state.SchemaVersion == 1) { foreach (var token in state.Tokens) token.GatewayBinding ??= string.Empty; state.SchemaVersion = CurrentSchemaVersion; } return new OpenClawDeviceIdentity(expectedDeviceId, expectedPublicKey, privateKey); } private async Task WriteStateAsync(DeviceState state, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(_statePath) ?? throw new InvalidOperationException("OpenClaw device state path has no parent directory."); Directory.CreateDirectory(directory); EnsureRestrictedPermissions(directory, isDirectory: true); var temporaryPath = Path.Combine(directory, $".{Path.GetFileName(_statePath)}.{Guid.NewGuid():N}.tmp"); try { await using (var stream = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken); await stream.FlushAsync(cancellationToken); } EnsureRestrictedPermissions(temporaryPath, isDirectory: false); File.Move(temporaryPath, _statePath, overwrite: true); EnsureRestrictedPermissions(_statePath, isDirectory: false); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } internal static void EnsureRestrictedPermissions(string path, bool isDirectory) { if (OperatingSystem.IsWindows()) return; var mode = isDirectory ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute : UnixFileMode.UserRead | UnixFileMode.UserWrite; File.SetUnixFileMode(path, mode); } private sealed class DeviceState { public int SchemaVersion { get; set; } public string DeviceId { get; set; } = string.Empty; public string PublicKey { get; set; } = string.Empty; public string PrivateKey { get; set; } = string.Empty; public List Tokens { get; set; } = []; } private sealed class DeviceTokenState { public string Role { get; set; } = "operator"; public string? GatewayBinding { get; set; } = string.Empty; public string Token { get; set; } = string.Empty; public List Scopes { get; set; } = []; } }