diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 8363696b..e37b07b1 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -2978,6 +2978,7 @@ namespace GameCult.Mesh /// /// Creates a CultNet RUDP client transport from the first peer authorized for a Verse role. /// + [Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")] public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer( string runtimeId, uint connectionId, @@ -2991,7 +2992,27 @@ namespace GameCult.Mesh { if (peers == null) throw new ArgumentNullException(nameof(peers)); if (leases == null) throw new ArgumentNullException(nameof(leases)); - var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at); + var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases, + at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null); + return CreateRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options); + } + + /// Creates a RUDP client from the first contact accepted by the authority resolver. + public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer( + string runtimeId, + uint connectionId, + CultMeshPeerCatalog peers, + CultMeshAuthorityResolver authority, + long authorityEpoch, + string verseId, + string role, + string? shardId = null, + string? resourceScope = null, + CultMeshRudpSocketOptions? options = null) + { + if (peers == null) throw new ArgumentNullException(nameof(peers)); + if (authority == null) throw new ArgumentNullException(nameof(authority)); + var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope); if (peer == null) { throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}."); @@ -3054,6 +3075,7 @@ namespace GameCult.Mesh /// /// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role. /// + [Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")] public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer( string runtimeId, uint connectionId, @@ -3067,7 +3089,27 @@ namespace GameCult.Mesh { if (peers == null) throw new ArgumentNullException(nameof(peers)); if (leases == null) throw new ArgumentNullException(nameof(leases)); - var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at); + var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases, + at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null); + return ConnectRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options); + } + + /// Creates and handshakes a RUDP client from the first contact accepted by the authority resolver. + public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer( + string runtimeId, + uint connectionId, + CultMeshPeerCatalog peers, + CultMeshAuthorityResolver authority, + long authorityEpoch, + string verseId, + string role, + string? shardId = null, + string? resourceScope = null, + CultMeshRudpClientOptions? options = null) + { + if (peers == null) throw new ArgumentNullException(nameof(peers)); + if (authority == null) throw new ArgumentNullException(nameof(authority)); + var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope); if (peer == null) { throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}."); @@ -3076,6 +3118,14 @@ namespace GameCult.Mesh return ConnectRudpClientForPeer(runtimeId, connectionId, peer, options); } + private sealed class CultMeshFixedAuthorityClock : ICultMeshClock + { + public CultMeshFixedAuthorityClock(DateTimeOffset utcNow) => UtcNow = utcNow; + public DateTimeOffset UtcNow { get; } + public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.CompletedTask; + } + /// /// Creates a committer for writing quorum simulation facts to a mesh database. /// diff --git a/src/GameCult.Mesh/CultMeshAuthority.cs b/src/GameCult.Mesh/CultMeshAuthority.cs index ed39b18f..05683317 100644 --- a/src/GameCult.Mesh/CultMeshAuthority.cs +++ b/src/GameCult.Mesh/CultMeshAuthority.cs @@ -1,17 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace GameCult.Mesh { - /// - /// Grants one peer authority to perform roles for a Verse over a bounded interval. - /// public sealed class CultMeshAuthorityLease { - /// - /// Creates an authority lease. - /// + public const string CurrentSchemaVersion = "gamecult.mesh.authority_lease.v1"; + public const string LegacySchemaVersion = "gamecult.mesh.authority_lease.v0"; + public CultMeshAuthorityLease( string leaseId, string verseId, @@ -21,7 +19,10 @@ namespace GameCult.Mesh string issuerRuntimeId, DateTimeOffset validFrom, DateTimeOffset expiresAt, - string? signature = null) + string? signature = null, + long? authorityEpoch = null, + IEnumerable? resourceScopes = null, + string? schemaVersion = null) { LeaseId = RequireNonEmpty(leaseId, nameof(leaseId)); VerseId = RequireNonEmpty(verseId, nameof(verseId)); @@ -32,109 +33,239 @@ namespace GameCult.Mesh ValidFrom = validFrom; ExpiresAt = expiresAt; Signature = signature; - if (expiresAt <= validFrom) - { - throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt)); - } + AuthorityEpoch = authorityEpoch; + ResourceScopes = Clean(resourceScopes); + SchemaVersion = schemaVersion ?? (authorityEpoch.HasValue ? CurrentSchemaVersion : LegacySchemaVersion); + if (expiresAt <= validFrom) throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt)); + if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch)); } - /// Gets the stable lease id. public string LeaseId { get; } - /// Gets the Verse id governed by the lease. public string VerseId { get; } - /// Gets the peer id receiving authority. public string PeerId { get; } - /// Gets the roles granted by this lease. public IReadOnlyList Roles { get; } - /// Gets shard ids covered by this lease. Empty means every shard in the Verse. public IReadOnlyList ShardIds { get; } - /// Gets the runtime that issued the lease. public string IssuerRuntimeId { get; } - /// Gets the first timestamp when the lease is valid. public DateTimeOffset ValidFrom { get; } - /// Gets the timestamp when the lease expires. public DateTimeOffset ExpiresAt { get; } - /// Gets an optional signature over the lease. public string? Signature { get; } + public long? AuthorityEpoch { get; } + public IReadOnlyList ResourceScopes { get; } + public string SchemaVersion { get; } - /// - /// Returns whether this lease is valid at the supplied time. - /// - public bool IsValidAt(DateTimeOffset at) - { - return at >= ValidFrom && at < ExpiresAt; - } + public bool IsValidAt(DateTimeOffset at) => at >= ValidFrom && at < ExpiresAt; - /// - /// Returns whether this lease covers a peer card, role, and shard. - /// + [Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This method denies leases that lack v1 signed authority evidence.")] public bool Covers(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null) { if (peer == null) throw new ArgumentNullException(nameof(peer)); - if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role)); - var time = at ?? DateTimeOffset.UtcNow; - return IsValidAt(time) && - string.Equals(VerseId, peer.VerseId, StringComparison.Ordinal) && - string.Equals(PeerId, peer.PeerId, StringComparison.Ordinal) && - string.Equals(LeaseId, peer.AuthorityLeaseId, StringComparison.Ordinal) && - Roles.Contains(role, StringComparer.Ordinal) && - peer.Roles.Contains(role, StringComparer.Ordinal) && - (string.IsNullOrWhiteSpace(shardId) || - ShardIds.Count == 0 || - ShardIds.Contains(shardId!, StringComparer.Ordinal)); + var resolver = CultMeshAuthorityResolver.CreateDenyByDefault( + new SingleLeaseSource(this), + at.HasValue ? new FixedClock(at.Value) : CultMeshSystemClock.Instance); + return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, AuthorityEpoch ?? -1)).IsAuthorized; } - private static string[] Clean(IEnumerable? values) + private static string[] Clean(IEnumerable? values) => + values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty(); + + private static string RequireNonEmpty(string value, string paramName) => + string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", paramName) : value; + + private sealed class SingleLeaseSource : ICultMeshAuthorityLeaseSource { - return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() - ?? Array.Empty(); + private readonly CultMeshAuthorityLease _lease; + public SingleLeaseSource(CultMeshAuthorityLease lease) => _lease = lease; + public CultMeshAuthorityLease? Get(string leaseId) => string.Equals(leaseId, _lease.LeaseId, StringComparison.Ordinal) ? _lease : null; } - private static string RequireNonEmpty(string value, string paramName) + private sealed class FixedClock : ICultMeshClock { - return string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Value must be non-empty.", paramName) - : value; + public FixedClock(DateTimeOffset now) => UtcNow = now; + public DateTimeOffset UtcNow { get; } + public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.CompletedTask; } } - /// - /// Local authority lease catalog. - /// - public sealed class CultMeshAuthorityLeaseCatalog + public interface ICultMeshAuthorityLeaseSource { - private readonly Dictionary _leases = new(StringComparer.Ordinal); + CultMeshAuthorityLease? Get(string leaseId); + } - /// Gets all known leases. - public IReadOnlyList Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray(); + public interface ICultMeshAuthoritySignatureVerifier + { + bool Verify(CultMeshAuthorityLease lease); + } - /// Adds or replaces an authority lease. - public void Upsert(CultMeshAuthorityLease lease) + public interface ICultMeshAuthorityRevocationSource + { + bool IsRevoked(string leaseId, long authorityEpoch); + } + + public enum CultMeshAuthorityDenialReason + { + None, + MissingLeaseReference, + LeaseNotFound, + UnsupportedLeaseVersion, + MissingSignature, + InvalidSignature, + NotYetValid, + Expired, + Revoked, + VerseMismatch, + PeerMismatch, + LeaseReferenceMismatch, + RoleNotGranted, + RoleNotAdvertised, + ShardNotGranted, + ShardNotAdvertised, + EpochMismatch, + ResourceScopeNotGranted + } + + public sealed class CultMeshAuthorityRequest + { + public CultMeshAuthorityRequest(CultMeshPeerCard peer, string role, string? shardId, long authorityEpoch, string? resourceScope = null) { - if (lease == null) throw new ArgumentNullException(nameof(lease)); - _leases[lease.LeaseId] = lease; + Peer = peer ?? throw new ArgumentNullException(nameof(peer)); + Role = string.IsNullOrWhiteSpace(role) ? throw new ArgumentException("Value must be non-empty.", nameof(role)) : role; + if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch)); + ShardId = shardId; + AuthorityEpoch = authorityEpoch; + ResourceScope = resourceScope; } - /// Gets a lease by id, if known. + public CultMeshPeerCard Peer { get; } + public string Role { get; } + public string? ShardId { get; } + public long AuthorityEpoch { get; } + public string? ResourceScope { get; } + } + + public sealed class CultMeshAuthorityDecision + { + internal CultMeshAuthorityDecision(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason) + { + Request = request; + Lease = lease; + DenialReason = reason; + } + + public CultMeshAuthorityRequest Request { get; } + public CultMeshAuthorityLease? Lease { get; } + public bool IsAuthorized => DenialReason == CultMeshAuthorityDenialReason.None; + public CultMeshAuthorityDenialReason DenialReason { get; } + public string? LeaseId => Lease?.LeaseId ?? Request.Peer.AuthorityLeaseId; + public string? IssuerRuntimeId => Lease?.IssuerRuntimeId; + } + + public sealed class CultMeshAuthorityResolver + { + private readonly ICultMeshAuthorityLeaseSource _leases; + private readonly ICultMeshAuthoritySignatureVerifier _signatures; + private readonly ICultMeshAuthorityRevocationSource _revocations; + private readonly ICultMeshClock _clock; + private readonly ICultMeshDiagnosticSink _diagnostics; + private long _diagnosticSequence; + + public CultMeshAuthorityResolver( + ICultMeshAuthorityLeaseSource leases, + ICultMeshAuthoritySignatureVerifier signatures, + ICultMeshAuthorityRevocationSource revocations, + ICultMeshClock clock, + ICultMeshDiagnosticSink? diagnostics = null) + { + _leases = leases ?? throw new ArgumentNullException(nameof(leases)); + _signatures = signatures ?? throw new ArgumentNullException(nameof(signatures)); + _revocations = revocations ?? throw new ArgumentNullException(nameof(revocations)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _diagnostics = diagnostics ?? CultMeshNullDiagnosticSink.Instance; + } + + public CultMeshAuthorityDecision Resolve(CultMeshAuthorityRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + var leaseId = request.Peer.AuthorityLeaseId; + if (string.IsNullOrWhiteSpace(leaseId)) return Decide(request, null, CultMeshAuthorityDenialReason.MissingLeaseReference); + var lease = _leases.Get(leaseId!); + if (lease == null) return Decide(request, null, CultMeshAuthorityDenialReason.LeaseNotFound); + if (!string.Equals(lease.SchemaVersion, CultMeshAuthorityLease.CurrentSchemaVersion, StringComparison.Ordinal) || !lease.AuthorityEpoch.HasValue) + return Decide(request, lease, CultMeshAuthorityDenialReason.UnsupportedLeaseVersion); + if (string.IsNullOrWhiteSpace(lease.Signature)) return Decide(request, lease, CultMeshAuthorityDenialReason.MissingSignature); + if (!_signatures.Verify(lease)) return Decide(request, lease, CultMeshAuthorityDenialReason.InvalidSignature); + if (_clock.UtcNow < lease.ValidFrom) return Decide(request, lease, CultMeshAuthorityDenialReason.NotYetValid); + if (_clock.UtcNow >= lease.ExpiresAt) return Decide(request, lease, CultMeshAuthorityDenialReason.Expired); + if (_revocations.IsRevoked(lease.LeaseId, lease.AuthorityEpoch.Value)) return Decide(request, lease, CultMeshAuthorityDenialReason.Revoked); + if (!string.Equals(lease.VerseId, request.Peer.VerseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.VerseMismatch); + if (!string.Equals(lease.PeerId, request.Peer.PeerId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.PeerMismatch); + if (!string.Equals(lease.LeaseId, leaseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.LeaseReferenceMismatch); + if (!lease.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotGranted); + if (!request.Peer.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotAdvertised); + if (!string.IsNullOrWhiteSpace(request.ShardId) && lease.ShardIds.Count != 0 && !lease.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal)) + return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotGranted); + if (!string.IsNullOrWhiteSpace(request.ShardId) && request.Peer.ShardIds.Count != 0 && !request.Peer.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal)) + return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotAdvertised); + if (lease.AuthorityEpoch.Value != request.AuthorityEpoch) return Decide(request, lease, CultMeshAuthorityDenialReason.EpochMismatch); + if (!string.IsNullOrWhiteSpace(request.ResourceScope) && !lease.ResourceScopes.Contains(request.ResourceScope!, StringComparer.Ordinal)) + return Decide(request, lease, CultMeshAuthorityDenialReason.ResourceScopeNotGranted); + return Decide(request, lease, CultMeshAuthorityDenialReason.None); + } + + internal static CultMeshAuthorityResolver CreateDenyByDefault(ICultMeshAuthorityLeaseSource leases, ICultMeshClock? clock = null) => + new CultMeshAuthorityResolver(leases, DenyAllSignatures.Instance, NoRevocations.Instance, clock ?? CultMeshSystemClock.Instance); + + private CultMeshAuthorityDecision Decide(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason) + { + var decision = new CultMeshAuthorityDecision(request, lease, reason); + _diagnostics.Emit(new CultMeshDiagnosticEvent( + Interlocked.Increment(ref _diagnosticSequence), _clock.UtcNow, CultMeshReliabilityOrgan.Authority, + CultMeshDiagnosticKind.AuthorityDecision, lease?.LeaseId ?? request.Peer.AuthorityLeaseId ?? "lease:missing", + request.Peer.PeerId, decision.IsAuthorized ? "authorized" : "denied", reason.ToString(), lease?.IssuerRuntimeId ?? "", + schemaVersion: "gamecult.mesh.authority_decision.v1")); + return decision; + } + + private sealed class DenyAllSignatures : ICultMeshAuthoritySignatureVerifier + { + public static DenyAllSignatures Instance { get; } = new DenyAllSignatures(); + public bool Verify(CultMeshAuthorityLease lease) => false; + } + + private sealed class NoRevocations : ICultMeshAuthorityRevocationSource + { + public static NoRevocations Instance { get; } = new NoRevocations(); + public bool IsRevoked(string leaseId, long authorityEpoch) => false; + } + } + + public sealed class CultMeshAuthorityLeaseCatalog : ICultMeshAuthorityLeaseSource + { + private readonly Dictionary _leases = new(StringComparer.Ordinal); + public IReadOnlyList Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray(); + public void Upsert(CultMeshAuthorityLease lease) { if (lease == null) throw new ArgumentNullException(nameof(lease)); _leases[lease.LeaseId] = lease; } public CultMeshAuthorityLease? Get(string leaseId) { if (string.IsNullOrWhiteSpace(leaseId)) throw new ArgumentException("Value must be non-empty.", nameof(leaseId)); return _leases.TryGetValue(leaseId, out var lease) ? lease : null; } - /// - /// Returns whether a peer card is authorized for a role and optional shard. - /// + [Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This compatibility path denies unsigned and unverifiable leases.")] public bool IsAuthorized(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null) { if (peer == null) throw new ArgumentNullException(nameof(peer)); - if (string.IsNullOrWhiteSpace(peer.AuthorityLeaseId)) - { - return false; - } + var lease = string.IsNullOrWhiteSpace(peer.AuthorityLeaseId) ? null : Get(peer.AuthorityLeaseId!); + var epoch = lease?.AuthorityEpoch ?? -1; + if (epoch < 0) return false; + var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(this, at.HasValue ? new CatalogFixedClock(at.Value) : null); + return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, epoch)).IsAuthorized; + } - return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) && - lease.Covers(peer, role, shardId, at); + private sealed class CatalogFixedClock : ICultMeshClock + { + public CatalogFixedClock(DateTimeOffset now) => UtcNow = now; + public DateTimeOffset UtcNow { get; } + public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.CompletedTask; } } } diff --git a/src/GameCult.Mesh/CultMeshGameSession.cs b/src/GameCult.Mesh/CultMeshGameSession.cs index d34c1860..f9d34f25 100644 --- a/src/GameCult.Mesh/CultMeshGameSession.cs +++ b/src/GameCult.Mesh/CultMeshGameSession.cs @@ -28,6 +28,12 @@ namespace GameCult.Mesh /// public CultMeshAuthorityLeaseCatalog? AuthorityLeases { get; set; } + /// + /// Gets or sets the sole authority resolver used by the session. When omitted, the + /// session installs a deny-by-default resolver over . + /// + public CultMeshAuthorityResolver? AuthorityResolver { get; set; } + /// /// Gets or sets simulation consensus options. /// @@ -68,6 +74,7 @@ namespace GameCult.Mesh VerseCatalog = options.VerseCatalog ?? new CultMeshVerseCatalog(); PeerCatalog = options.PeerCatalog ?? new CultMeshPeerCatalog(); AuthorityLeases = options.AuthorityLeases ?? new CultMeshAuthorityLeaseCatalog(); + AuthorityResolver = options.AuthorityResolver ?? CultMeshAuthorityResolver.CreateDenyByDefault(AuthorityLeases); ObservationHub = new CultNetSimulationObservationHub(options.ConsensusOptions); FactCommitter = new CultMeshSimulationFactCommitter(Node.Database); @@ -117,6 +124,9 @@ namespace GameCult.Mesh /// public CultMeshAuthorityLeaseCatalog AuthorityLeases { get; } + /// Gets the session authority decision owner. + public CultMeshAuthorityResolver AuthorityResolver { get; } + /// /// Gets the session simulation observation hub. /// diff --git a/src/GameCult.Mesh/CultMeshPeerExchange.cs b/src/GameCult.Mesh/CultMeshPeerExchange.cs index 109df07d..2489ebac 100644 --- a/src/GameCult.Mesh/CultMeshPeerExchange.cs +++ b/src/GameCult.Mesh/CultMeshPeerExchange.cs @@ -192,23 +192,40 @@ namespace GameCult.Mesh } /// Finds peers that advertise a Verse role and are authorized by the supplied lease catalog. + [Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")] public IReadOnlyList FindAuthorized( string verseId, string role, CultMeshAuthorityLeaseCatalog leases, string? shardId = null, DateTimeOffset? at = null) + { + if (leases == null) throw new ArgumentNullException(nameof(leases)); + var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases, + at.HasValue ? new PeerCatalogFixedClock(at.Value) : null); + return FindAuthorized(verseId, role, resolver, 0, shardId); + } + + /// Finds contact candidates accepted by the authority resolver. + public IReadOnlyList FindAuthorized( + string verseId, + string role, + CultMeshAuthorityResolver resolver, + long authorityEpoch, + string? shardId = null, + string? resourceScope = null) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(verseId)) throw new ArgumentException("Value must be non-empty.", nameof(verseId)); if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role)); - if (leases == null) throw new ArgumentNullException(nameof(leases)); + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); return Find(verseId, role) - .Where(peer => leases.IsAuthorized(peer, role, shardId, at)) + .Where(peer => resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, authorityEpoch, resourceScope)).IsAuthorized) .ToArray(); } /// Returns the first peer that advertises a Verse role and is authorized by the supplied lease catalog. + [Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")] public CultMeshPeerCard? FirstAuthorized( string verseId, string role, @@ -219,6 +236,18 @@ namespace GameCult.Mesh return FindAuthorized(verseId, role, leases, shardId, at).FirstOrDefault(); } + /// Returns the first contact candidate accepted by the authority resolver. + public CultMeshPeerCard? FirstAuthorized( + string verseId, + string role, + CultMeshAuthorityResolver resolver, + long authorityEpoch, + string? shardId = null, + string? resourceScope = null) + { + return FindAuthorized(verseId, role, resolver, authorityEpoch, shardId, resourceScope).FirstOrDefault(); + } + /// public void Dispose() { @@ -239,6 +268,13 @@ namespace GameCult.Mesh throw new ObjectDisposedException(nameof(CultMeshPeerCatalog)); } } + + private sealed class PeerCatalogFixedClock : ICultMeshClock + { + public PeerCatalogFixedClock(DateTimeOffset utcNow) => UtcNow = utcNow; + public DateTimeOffset UtcNow { get; } + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } /// diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md index 65e97a9c..0144524e 100644 --- a/src/GameCult.Mesh/docs/reliability-migration-plan.md +++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md @@ -104,6 +104,45 @@ must never confer authority. **Demotion:** an advertised peer card is only contact evidence. It is never an authorization result. +#### Phase 3 body map + +**Owner:** `CultMeshAuthorityResolver` is the only component that decides +whether a peer may act. Decisions are evaluated on demand and are never cached +as grants. + +**Inputs:** `CultMeshAuthorityRequest`, an `ICultMeshAuthorityLeaseSource`, an +`ICultMeshAuthoritySignatureVerifier`, an +`ICultMeshAuthorityRevocationSource`, the current authority epoch, and an +injected `ICultMeshClock`. + +**Outputs:** `CultMeshAuthorityDecision`, carrying either the accepted lease +evidence identifiers or one typed denial reason. Authority diagnostics carry +lease, peer, and issuer identifiers but never signature material. + +**Derived state:** peer selection is a projection over contact candidates and +fresh resolver decisions. The lease catalog is only a mutable evidence source; +the peer catalog is only a contact source. + +**Forbidden writers:** lease replacement, peer-card publication, successful +transport connection, cached presence, and wall-clock defaults cannot grant or +restore authority. Revocation and epoch policy remain outside both catalogs. + +**Shared paths:** gameplay sessions expose their resolver; peer selection and +both privileged RUDP creation paths call that resolver. Compatibility overloads +delegate to the same resolver with deny-by-default signature policy. + +**Cut line:** `CultMeshAuthorityLease.Covers`, +`CultMeshAuthorityLeaseCatalog.IsAuthorized`, and catalog-owned +`FindAuthorized`/`FirstAuthorized` no longer contain authorization logic. +Version-zero or unsigned leases are not reinterpreted as trusted epoch-zero +leases. Callers must supply a version-one lease, explicit epoch, signature +verifier, and revocation source. + +**Compatibility inventory:** the old lease/catalog peer-selection and RUDP +overloads remain source-compatible for migration, but cannot authorize because +they have no signature-verification authority. They delegate to a +deny-by-default resolver and should be replaced by resolver-taking overloads. + ### Content transfer scheduler **Owner:** `CultMeshContentTransferService`. diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs new file mode 100644 index 00000000..d05fa93f --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs @@ -0,0 +1,208 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +public sealed class CultMeshAuthorityResolverTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 13, 12, 0, 0, TimeSpan.Zero); + + [Test] + public void Resolve_AcceptsSignedLeaseWithExactScopeAndEpoch() + { + var fixture = CreateFixture(); + var decision = fixture.Resolver.Resolve(fixture.Request()); + + decision.IsAuthorized.Should().BeTrue(); + decision.DenialReason.Should().Be(CultMeshAuthorityDenialReason.None); + decision.LeaseId.Should().Be("lease:pilot"); + decision.IssuerRuntimeId.Should().Be("odin"); + } + + [TestCase(null, CultMeshAuthorityDenialReason.MissingSignature)] + [TestCase("bad-signature", CultMeshAuthorityDenialReason.InvalidSignature)] + public void Resolve_RejectsMissingOrInvalidSignature(string? signature, CultMeshAuthorityDenialReason expected) + { + var fixture = CreateFixture(signature: signature); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(expected); + } + + [Test] + public void Resolve_RejectsLegacyUnsignedLeaseInsteadOfTreatingItAsEpochZero() + { + var catalog = new CultMeshAuthorityLeaseCatalog(); + var lease = new CultMeshAuthorityLease("legacy", "verse", "peer", new[] { "pilot" }, null, "odin", Now.AddMinutes(-1), Now.AddMinutes(1)); + catalog.Upsert(lease); + var peer = new CultMeshPeerCard("peer", "verse", Array.Empty(), new[] { "pilot" }, authorityLeaseId: "legacy"); + var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), new Revocations(), new ManualClock(Now)); + + resolver.Resolve(new CultMeshAuthorityRequest(peer, "pilot", null, 0)).DenialReason + .Should().Be(CultMeshAuthorityDenialReason.UnsupportedLeaseVersion); +#pragma warning disable CS0618 + catalog.IsAuthorized(peer, "pilot", at: Now).Should().BeFalse(); +#pragma warning restore CS0618 + } + + [Test] + public void Resolve_UsesInjectedClockForNotYetValidAndExpiry() + { + var fixture = CreateFixture(validFrom: Now.AddMinutes(1), expiresAt: Now.AddMinutes(2)); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.NotYetValid); + fixture.Clock.Advance(TimeSpan.FromMinutes(1)); + fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue(); + fixture.Clock.Advance(TimeSpan.FromMinutes(1)); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired); + } + + [Test] + public void Resolve_RejectsRevokedLeaseAndCatalogReplacementCannotReviveIt() + { + var fixture = CreateFixture(revoked: true); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked); + + fixture.Catalog.Upsert(CreateLease(signature: "valid-replacement")); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked); + } + + [Test] + public void Resolve_CatalogReplacementCannotOverrideEpochPolicy() + { + var fixture = CreateFixture(); + fixture.Catalog.Upsert(new CultMeshAuthorityLease( + "lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin", + Now.AddMinutes(-1), Now.AddMinutes(1), "valid-new-epoch", 8, new[] { "body:world" })); + + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.EpochMismatch); + } + + [Test] + public void Resolve_ClockMovementExpiresLeaseWithoutCatalogMutation() + { + var fixture = CreateFixture(expiresAt: Now.AddSeconds(1)); + fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue(); + fixture.Clock.Advance(TimeSpan.FromSeconds(1)); + fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired); + } + + [Test] + public void Resolve_ReportsDistinctScopeDenials() + { + AssertDenied(CreateFixture(peerVerse: "other").Request(), CreateFixture(peerVerse: "other").Resolver, CultMeshAuthorityDenialReason.VerseMismatch); + AssertDenied(CreateFixture(peerId: "other").Request(), CreateFixture(peerId: "other").Resolver, CultMeshAuthorityDenialReason.PeerMismatch); + AssertDenied(CreateFixture(requestRole: "trade").Request(), CreateFixture(requestRole: "trade").Resolver, CultMeshAuthorityDenialReason.RoleNotGranted); + AssertDenied(CreateFixture(requestShard: "shard:b").Request(), CreateFixture(requestShard: "shard:b").Resolver, CultMeshAuthorityDenialReason.ShardNotGranted); + AssertDenied(CreateFixture(requestEpoch: 8).Request(), CreateFixture(requestEpoch: 8).Resolver, CultMeshAuthorityDenialReason.EpochMismatch); + AssertDenied(CreateFixture(requestScope: "body:other").Request(), CreateFixture(requestScope: "body:other").Resolver, CultMeshAuthorityDenialReason.ResourceScopeNotGranted); + } + + [Test] + public void Resolve_NewPeerPublicationCannotReviveExpiredAuthority() + { + var fixture = CreateFixture(expiresAt: Now); + fixture.CatalogPeers.Upsert(fixture.Peer); + fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty(); + + fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot")); + fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty(); + } + + [Test] + public void Resolve_NewPeerPublicationCannotReviveRevokedAuthority() + { + var fixture = CreateFixture(revoked: true); + fixture.CatalogPeers.Upsert(fixture.Peer); + fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty(); + + fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot")); + fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty(); + } + + [Test] + public void Diagnostics_CarryEvidenceIdentifiersButNeverSignatureMaterial() + { + var fixture = CreateFixture(signature: "super-secret-signature"); + fixture.Resolver.Resolve(fixture.Request()); + + var diagnostic = fixture.Diagnostics.Snapshot().Should().ContainSingle().Subject; + diagnostic.OperationId.Should().Be("lease:pilot"); + diagnostic.SubjectId.Should().Be("peer:pilot"); + diagnostic.SourceId.Should().Be("odin"); + string.Join("|", diagnostic.OperationId, diagnostic.SubjectId, diagnostic.SourceId, diagnostic.ReasonCode, diagnostic.Endpoint) + .Should().NotContain("super-secret-signature"); + } + + [Test] + public void PeerSelectionAndPrivilegedRudpHelpersUseResolverDecision() + { + var fixture = CreateFixture(); + fixture.CatalogPeers.Upsert(fixture.Peer); + fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world") + .Should().BeSameAs(fixture.Peer); + +#pragma warning disable CS0618 + fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Catalog, "shard:a", Now).Should().BeNull(); + Action legacy = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Catalog, "verse:aetheria", "pilot", "shard:a", Now); +#pragma warning restore CS0618 + legacy.Should().Throw().WithMessage("No authorized RUDP peer*"); + + Action resolved = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Resolver, 7, "verse:aetheria", "pilot", "shard:a", "body:world"); + resolved.Should().Throw().WithMessage("Peer peer:pilot does not advertise a RUDP endpoint."); + } + + private static void AssertDenied(CultMeshAuthorityRequest request, CultMeshAuthorityResolver resolver, CultMeshAuthorityDenialReason reason) => + resolver.Resolve(request).DenialReason.Should().Be(reason); + + private static Fixture CreateFixture( + string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null, + bool revoked = false, string peerId = "peer:pilot", string peerVerse = "verse:aetheria", + string requestRole = "pilot", string requestShard = "shard:a", long requestEpoch = 7, string requestScope = "body:world") + { + var catalog = new CultMeshAuthorityLeaseCatalog(); + catalog.Upsert(CreateLease(signature, validFrom, expiresAt)); + var clock = new ManualClock(Now); + var revocations = new Revocations(revoked ? new[] { ("lease:pilot", 7L) } : null); + var diagnostics = new CultMeshDiagnosticBuffer(); + var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), revocations, clock, diagnostics); + var peer = new CultMeshPeerCard(peerId, peerVerse, Array.Empty(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot"); + return new Fixture(catalog, new CultMeshPeerCatalog(), clock, diagnostics, resolver, peer, requestRole, requestShard, requestEpoch, requestScope); + } + + private static CultMeshAuthorityLease CreateLease(string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null) => + new("lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin", + validFrom ?? Now.AddMinutes(-1), expiresAt ?? Now.AddMinutes(1), signature, 7, new[] { "body:world" }); + + private sealed record Fixture( + CultMeshAuthorityLeaseCatalog Catalog, CultMeshPeerCatalog CatalogPeers, ManualClock Clock, + CultMeshDiagnosticBuffer Diagnostics, CultMeshAuthorityResolver Resolver, CultMeshPeerCard Peer, + string Role, string Shard, long Epoch, string Scope) + { + public CultMeshAuthorityRequest Request() => new(Peer, Role, Shard, Epoch, Scope); + } + + private sealed class SignatureVerifier : ICultMeshAuthoritySignatureVerifier + { + public bool Verify(CultMeshAuthorityLease lease) => lease.Signature?.StartsWith("valid", StringComparison.Ordinal) == true; + } + + private sealed class Revocations : ICultMeshAuthorityRevocationSource + { + private readonly HashSet<(string, long)> _revoked; + public Revocations(IEnumerable<(string, long)>? revoked = null) => _revoked = revoked?.ToHashSet() ?? new(); + public bool IsRevoked(string leaseId, long authorityEpoch) => _revoked.Contains((leaseId, authorityEpoch)); + } + + private sealed class ManualClock : ICultMeshClock + { + public ManualClock(DateTimeOffset now) => UtcNow = now; + public DateTimeOffset UtcNow { get; private set; } + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) { Advance(delay); return Task.CompletedTask; } + public void Advance(TimeSpan duration) => UtcNow += duration; + } +}