using System.Security.Cryptography; using System.Text; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Client; public enum ConnectionTicketConsumptionResult { Accepted = 1, NotFound = 2, Expired = 3, Rejected = 4, AlreadyConsumed = 5, Revoked = 6, } public sealed class ConnectionTicketValidator : IDisposable { private readonly object _gate = new(); private readonly Dictionary _tickets = []; private readonly int _maximumAuthorizedTickets; private readonly IConnectionTicketClock _clock; private readonly byte[] _fingerprintKey = new byte[32]; private bool _disposed; public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024) : this(maximumAuthorizedTickets, new SystemConnectionTicketClock()) { } internal ConnectionTicketValidator( int maximumAuthorizedTickets, IConnectionTicketClock clock) { if (maximumAuthorizedTickets is < 1 or > 10_000) { throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets)); } _maximumAuthorizedTickets = maximumAuthorizedTickets; _clock = clock ?? throw new ArgumentNullException(nameof(clock)); RandomNumberGenerator.Fill(_fingerprintKey); } public bool TryAuthorize( JoinAttemptId attemptId, string connectionTicket, DateTimeOffset expiresAt) { lock (_gate) { ThrowIfDisposed(); DateTimeOffset now = _clock.UtcNow; if (attemptId.Value == Guid.Empty || !ContractValidation.IsConnectionTicketValid(connectionTicket) || expiresAt <= now) { return false; } RemoveExpired(now); byte[] fingerprint = Fingerprint(connectionTicket); if (_tickets.TryGetValue(attemptId, out TicketEntry? current)) { bool idempotent = current.State == TicketState.Active && current.ExpiresAt == expiresAt && CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint); CryptographicOperations.ZeroMemory(fingerprint); return idempotent; } if (_tickets.Count >= _maximumAuthorizedTickets) { CryptographicOperations.ZeroMemory(fingerprint); return false; } _tickets.Add(attemptId, new(fingerprint, expiresAt)); return true; } } public ConnectionTicketConsumptionResult Consume( JoinAttemptId attemptId, string connectionTicket) { lock (_gate) { ThrowIfDisposed(); DateTimeOffset now = _clock.UtcNow; if (attemptId.Value == Guid.Empty || !ContractValidation.IsConnectionTicketValid(connectionTicket)) { return ConnectionTicketConsumptionResult.Rejected; } if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry)) { RemoveExpired(now); return ConnectionTicketConsumptionResult.NotFound; } if (entry.ExpiresAt <= now) { Remove(attemptId, entry); return ConnectionTicketConsumptionResult.Expired; } if (entry.State == TicketState.Revoked) { return ConnectionTicketConsumptionResult.Revoked; } if (entry.State == TicketState.Consumed) { return ConnectionTicketConsumptionResult.AlreadyConsumed; } byte[] supplied = Fingerprint(connectionTicket); bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied); CryptographicOperations.ZeroMemory(supplied); if (!matches) { return ConnectionTicketConsumptionResult.Rejected; } entry.State = TicketState.Consumed; return ConnectionTicketConsumptionResult.Accepted; } } public bool Revoke(JoinAttemptId attemptId) { lock (_gate) { ThrowIfDisposed(); RemoveExpired(_clock.UtcNow); if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry)) { return false; } entry.State = TicketState.Revoked; CryptographicOperations.ZeroMemory(entry.Fingerprint); return true; } } public void Dispose() { lock (_gate) { if (_disposed) { return; } foreach (TicketEntry entry in _tickets.Values) { CryptographicOperations.ZeroMemory(entry.Fingerprint); } _tickets.Clear(); CryptographicOperations.ZeroMemory(_fingerprintKey); _disposed = true; } } public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]"; private byte[] Fingerprint(string ticket) { byte[] encoded = Encoding.ASCII.GetBytes(ticket); try { using HMACSHA256 hmac = new(_fingerprintKey); return hmac.ComputeHash(encoded); } finally { CryptographicOperations.ZeroMemory(encoded); } } private void RemoveExpired(DateTimeOffset now) { foreach (KeyValuePair item in _tickets .Where(item => item.Value.ExpiresAt <= now) .ToArray()) { Remove(item.Key, item.Value); } } private void Remove(JoinAttemptId attemptId, TicketEntry entry) { CryptographicOperations.ZeroMemory(entry.Fingerprint); _tickets.Remove(attemptId); } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(ConnectionTicketValidator)); } } private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt) { public byte[] Fingerprint { get; } = fingerprint; public DateTimeOffset ExpiresAt { get; } = expiresAt; public TicketState State { get; set; } } private enum TicketState { Active = 0, Consumed = 1, Revoked = 2, } } internal interface IConnectionTicketClock { DateTimeOffset UtcNow { get; } } internal sealed class SystemConnectionTicketClock : IConnectionTicketClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }