feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
quality-gate / quality (push) Successful in 1m1s
Closes #10
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user