Files
nexus/backend/Services/OpenClawDeviceIdentityStore.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

496 lines
18 KiB
C#

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<byte> 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<string> Scopes,
string Role,
string GatewayBinding = "");
public interface IOpenClawDeviceIdentityStore
{
string StatePath { get; }
Task<OpenClawDeviceIdentity> LoadOrCreateAsync(CancellationToken cancellationToken = default);
Task<OpenClawDeviceToken?> LoadTokenAsync(
string deviceId,
string role,
CancellationToken cancellationToken = default);
Task<OpenClawDeviceToken?> LoadTokenAsync(
string deviceId,
string role,
string gatewayBinding,
CancellationToken cancellationToken = default);
Task StoreTokenAsync(
string deviceId,
string role,
string token,
IReadOnlyCollection<string> scopes,
CancellationToken cancellationToken = default);
Task StoreTokenAsync(
string deviceId,
string role,
string gatewayBinding,
string token,
IReadOnlyCollection<string> scopes,
CancellationToken cancellationToken = default);
Task<bool> RemoveTokenAsync(
string deviceId,
string role,
string gatewayBinding,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 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.
/// </summary>
public sealed class OpenClawDeviceIdentityStore : IOpenClawDeviceIdentityStore
{
private const int CurrentSchemaVersion = 2;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly ILogger<OpenClawDeviceIdentityStore> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _statePath;
private DeviceState? _state;
private OpenClawDeviceIdentity? _identity;
public OpenClawDeviceIdentityStore(
IOptions<GatewayConnectorOptions> options,
ILogger<OpenClawDeviceIdentityStore> logger)
{
_logger = logger;
_statePath = ResolveStatePath(options.Value.DeviceStatePath);
}
public string StatePath => _statePath;
public async Task<OpenClawDeviceIdentity> 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<OpenClawDeviceToken?> LoadTokenAsync(
string deviceId,
string role,
CancellationToken cancellationToken = default)
=> await LoadTokenAsync(
deviceId,
role,
gatewayBinding: string.Empty,
cancellationToken);
public async Task<OpenClawDeviceToken?> 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<string> 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<string> 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<bool> 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<DeviceState> 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<DeviceState>(
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<DeviceTokenState> 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<string> Scopes { get; set; } = [];
}
}