496 lines
18 KiB
C#
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; } = [];
|
|
}
|
|
}
|