feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
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; } = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user