233 lines
6.6 KiB
C#
233 lines
6.6 KiB
C#
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<JoinAttemptId, TicketEntry> _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<JoinAttemptId, TicketEntry> 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;
|
|
}
|