265 lines
10 KiB
C#
265 lines
10 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Models;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
public sealed record OpenClawWriteGateDecision(
|
|
bool Allowed,
|
|
string State,
|
|
string Message,
|
|
string? Recovery = null)
|
|
{
|
|
public static OpenClawWriteGateDecision Permit()
|
|
=> new(true, "available", "OpenClaw write boundary verified.");
|
|
|
|
public static OpenClawWriteGateDecision Block(
|
|
string state,
|
|
string message,
|
|
string? recovery = null)
|
|
=> new(false, state, message, recovery);
|
|
}
|
|
|
|
public interface IOpenClawWriteGate
|
|
{
|
|
Task<OpenClawWriteGateDecision> EvaluateAsync(
|
|
string method,
|
|
string requiredScope = "operator.admin",
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Binds every OpenClaw write to the one adopted primary profile. This is a
|
|
/// local policy boundary in addition to OpenClaw's own scope enforcement.
|
|
/// </summary>
|
|
public sealed class OpenClawWriteGate(
|
|
IServiceScopeFactory scopeFactory,
|
|
IGatewayConnector connector,
|
|
IOptions<GatewayConnectorOptions> gatewayOptions,
|
|
IConfiguration configuration) : IOpenClawWriteGate
|
|
{
|
|
public async Task<OpenClawWriteGateDecision> EvaluateAsync(
|
|
string method,
|
|
string requiredScope = "operator.admin",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!configuration.GetValue(
|
|
"OpenClawSetup:ExternalClientIdentitySupported",
|
|
false)
|
|
|| !gatewayOptions.Value.ExternalClientIdentitySupported)
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"experimental_blocked",
|
|
"OpenClaw has not declared the external Nexus client identity supported.",
|
|
"Keep writes disabled until the pinned OpenClaw release registers the Nexus client id.");
|
|
}
|
|
|
|
try
|
|
{
|
|
OpenClawGatewayProtocol.ValidateExternalClientIdentity(
|
|
gatewayOptions.Value);
|
|
}
|
|
catch (OpenClawGatewayRpcException exception)
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
NormalizeCode(exception.Code),
|
|
exception.Message,
|
|
"Use only an officially registered external Nexus operator identity.");
|
|
}
|
|
|
|
await using var scope = scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
var profile = await db.OpenClawConnectionProfiles
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(
|
|
item => item.ProfileId ==
|
|
OpenClawConnectionProfile.PrimaryProfileId,
|
|
cancellationToken);
|
|
|
|
if (profile is null
|
|
|| profile.AdoptionState != OpenClawAdoptionStates.Adopted
|
|
|| !profile.ManagementEnabled)
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"management_disabled",
|
|
"The adopted primary OpenClaw profile is not approved for management.",
|
|
"An owner must adopt, verify and explicitly enable management for the primary profile.");
|
|
}
|
|
|
|
if (connector.ConnectionState != GatewayConnectionState.Connected)
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"gateway_unavailable",
|
|
"OpenClaw Gateway is not connected.",
|
|
"Restore the verified primary Gateway connection and retry.");
|
|
}
|
|
|
|
if (!EndpointsEqual(profile.Endpoint, connector.ActiveEndpoint))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"endpoint_trust_mismatch",
|
|
"The connected OpenClaw endpoint is not the adopted primary endpoint.",
|
|
"Reconnect and re-verify the adopted primary profile before enabling writes.");
|
|
}
|
|
|
|
var tlsPinRequired = RequiresTlsPin(profile.Endpoint);
|
|
if (tlsPinRequired
|
|
&& (string.IsNullOrWhiteSpace(profile.TlsCertificateFingerprint)
|
|
|| string.IsNullOrWhiteSpace(
|
|
connector.ActiveTlsFingerprint)))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"tls_trust_missing",
|
|
"The adopted WSS OpenClaw endpoint has no complete TLS fingerprint binding.",
|
|
"Probe the endpoint, confirm its certificate fingerprint and re-adopt the connection.");
|
|
}
|
|
|
|
if ((tlsPinRequired
|
|
|| !string.IsNullOrWhiteSpace(
|
|
profile.TlsCertificateFingerprint)
|
|
|| !string.IsNullOrWhiteSpace(
|
|
connector.ActiveTlsFingerprint))
|
|
&& !FingerprintsEqual(
|
|
profile.TlsCertificateFingerprint,
|
|
connector.ActiveTlsFingerprint))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"tls_trust_mismatch",
|
|
"The active OpenClaw TLS fingerprint differs from the adopted primary profile.",
|
|
"Confirm the certificate fingerprint and re-adopt the connection.");
|
|
}
|
|
|
|
if (!BoundIdentityEquals(profile.DeviceId, connector.DeviceId))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"device_trust_mismatch",
|
|
"The active OpenClaw device identity differs from the adopted primary profile.",
|
|
"Pair and verify the expected Nexus device before enabling writes.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(profile.RequiredVersion)
|
|
&& !string.Equals(
|
|
profile.RequiredVersion,
|
|
connector.GatewayVersion,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"version_mismatch",
|
|
"The connected OpenClaw version differs from the adopted primary profile.",
|
|
"Restore the pinned OpenClaw version and re-verify the connection.");
|
|
}
|
|
|
|
var capabilityHash = BuildCapabilityHash(connector);
|
|
if (string.IsNullOrWhiteSpace(profile.CapabilityHash)
|
|
|| !string.Equals(
|
|
profile.CapabilityHash,
|
|
capabilityHash,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"capability_drift",
|
|
"OpenClaw capabilities changed after management approval.",
|
|
"Re-verify the primary profile and approve its current capability set.");
|
|
}
|
|
|
|
if (!connector.Supports(method))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"capability_missing",
|
|
$"OpenClaw does not advertise required method '{method}'.",
|
|
"Use a compatible OpenClaw release and re-verify capabilities.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(requiredScope)
|
|
&& !connector.GrantedScopes.Contains(requiredScope))
|
|
{
|
|
return OpenClawWriteGateDecision.Block(
|
|
"scope_upgrade_required",
|
|
$"OpenClaw did not grant {requiredScope}.",
|
|
"Approve the explicit scope upgrade, reconnect and re-verify the primary profile.");
|
|
}
|
|
|
|
return OpenClawWriteGateDecision.Permit();
|
|
}
|
|
|
|
public static string BuildCapabilityHash(IGatewayConnector gateway)
|
|
{
|
|
var contract = string.Join(
|
|
"\n",
|
|
new[]
|
|
{
|
|
gateway.GatewayVersion ?? string.Empty,
|
|
gateway.RequiredVersion ?? string.Empty,
|
|
gateway.ProtocolVersion?.ToString() ?? string.Empty,
|
|
string.Join(",", gateway.GrantedScopes.Order(StringComparer.Ordinal)),
|
|
string.Join(",", gateway.AdvertisedMethods.Order(StringComparer.Ordinal)),
|
|
string.Join(",", gateway.AdvertisedEvents.Order(StringComparer.Ordinal))
|
|
});
|
|
return Convert.ToHexString(
|
|
SHA256.HashData(Encoding.UTF8.GetBytes(contract)));
|
|
}
|
|
|
|
private static bool EndpointsEqual(string? expected, string? actual)
|
|
{
|
|
if (!Uri.TryCreate(expected, UriKind.Absolute, out var expectedUri)
|
|
|| !Uri.TryCreate(actual, UriKind.Absolute, out var actualUri))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(
|
|
NormalizeEndpoint(expectedUri),
|
|
NormalizeEndpoint(actualUri),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string NormalizeEndpoint(Uri value)
|
|
{
|
|
var builder = new UriBuilder(value)
|
|
{
|
|
Host = value.Host.ToLowerInvariant(),
|
|
Path = string.IsNullOrWhiteSpace(value.AbsolutePath)
|
|
? "/"
|
|
: value.AbsolutePath.TrimEnd('/') + "/",
|
|
Query = string.Empty,
|
|
Fragment = string.Empty
|
|
};
|
|
return builder.Uri.AbsoluteUri.TrimEnd('/');
|
|
}
|
|
|
|
private static bool FingerprintsEqual(string? expected, string? actual)
|
|
=> string.Equals(
|
|
NormalizeFingerprint(expected),
|
|
NormalizeFingerprint(actual),
|
|
StringComparison.Ordinal);
|
|
|
|
private static bool RequiresTlsPin(string? endpoint)
|
|
=> Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)
|
|
&& string.Equals(
|
|
uri.Scheme,
|
|
Uri.UriSchemeWss,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string NormalizeFingerprint(string? value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? string.Empty
|
|
: new string(value
|
|
.Where(Uri.IsHexDigit)
|
|
.Select(char.ToUpperInvariant)
|
|
.ToArray());
|
|
|
|
private static bool BoundIdentityEquals(string? expected, string? actual)
|
|
=> string.Equals(
|
|
expected?.Trim() ?? string.Empty,
|
|
actual?.Trim() ?? string.Empty,
|
|
StringComparison.Ordinal);
|
|
|
|
private static string NormalizeCode(string value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? "external_identity_invalid"
|
|
: value.Trim().ToLowerInvariant().Replace('-', '_');
|
|
}
|