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 EvaluateAsync( string method, string requiredScope = "operator.admin", CancellationToken cancellationToken = default); } /// /// Binds every OpenClaw write to the one adopted primary profile. This is a /// local policy boundary in addition to OpenClaw's own scope enforcement. /// public sealed class OpenClawWriteGate( IServiceScopeFactory scopeFactory, IGatewayConnector connector, IOptions gatewayOptions, IConfiguration configuration) : IOpenClawWriteGate { public async Task 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(); 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('-', '_'); }