using LumaTunnel.Server.Core.Persistence; namespace LumaTunnel.Server.Core.Security; public sealed class PairingCodeService(AtomicJsonStore store) : IDisposable { private readonly AtomicJsonStore _store = store; private readonly SemaphoreSlim _gate = new(1, 1); private PairingCodeDatabase _database = new(); public async Task InitializeAsync(CancellationToken cancellationToken = default) => _database = await _store.LoadOrCreateAsync(static () => new PairingCodeDatabase(), cancellationToken).ConfigureAwait(false); public async Task<(string Code, DateTimeOffset ExpiresAtUtc)> CreateAsync(TimeSpan lifetime, CancellationToken cancellationToken = default) { if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(lifetime), "Pairing code lifetime must be between zero and one day."); var code = TokenUtilities.CreatePairingCode(); var expires = DateTimeOffset.UtcNow.Add(lifetime); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { _database = await _store.LoadOrCreateAsync(static () => new PairingCodeDatabase(), cancellationToken).ConfigureAwait(false); _database.Codes.RemoveAll(static x => x.Used || x.ExpiresAtUtc <= DateTimeOffset.UtcNow); _database.Codes.Add(new PairingCodeRecord { CodeHash = TokenUtilities.Hash(code), ExpiresAtUtc = expires }); await _store.SaveAsync(_database, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } return (code, expires); } public async Task ConsumeAsync(string code, CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Pairing codes are normally created by a separate elevated CLI process. _database = await _store.LoadOrCreateAsync(static () => new PairingCodeDatabase(), cancellationToken).ConfigureAwait(false); var now = DateTimeOffset.UtcNow; var match = _database.Codes.FirstOrDefault(x => !x.Used && x.ExpiresAtUtc > now && TokenUtilities.FixedTimeHashEquals(code, x.CodeHash)); if (match is null) return false; var index = _database.Codes.IndexOf(match); _database.Codes[index] = match with { Used = true }; await _store.SaveAsync(_database, cancellationToken).ConfigureAwait(false); return true; } finally { _gate.Release(); } } public void Dispose() { _gate.Dispose(); _store.Dispose(); GC.SuppressFinalize(this); } }