using System.Security.Cryptography; using System.Text; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Server.Browser; internal sealed class EphemeralCursorProtector : IDisposable { private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); private bool _disposed; public string Protect(string prefix, ReadOnlySpan payload) { ObjectDisposedException.ThrowIf(_disposed, this); string content = $"{prefix}.{EncodeBytes(payload)}"; byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content)); try { string cursor = $"{content}.{EncodeBytes(signature)}"; return ContractValidation.IsCursorValid(cursor) ? cursor : throw new InvalidOperationException("The protected cursor exceeds its contract limit."); } finally { CryptographicOperations.ZeroMemory(signature); } } public bool TryUnprotect(string prefix, string? cursor, out byte[] payload) { payload = []; if (_disposed || !ContractValidation.IsCursorValid(cursor)) { return false; } string[] segments = cursor!.Split('.'); if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal)) { return false; } byte[] expected = HMACSHA256.HashData( _key, Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); if (!TryDecodeBytes(segments[2], out byte[] supplied)) { CryptographicOperations.ZeroMemory(expected); return false; } bool validSignature = supplied.Length == expected.Length && CryptographicOperations.FixedTimeEquals(supplied, expected); CryptographicOperations.ZeroMemory(supplied); CryptographicOperations.ZeroMemory(expected); return validSignature && TryDecodeBytes(segments[1], out payload); } public void Dispose() { if (!_disposed) { _disposed = true; CryptographicOperations.ZeroMemory(_key); } } public override string ToString() => "[EphemeralCursorProtector: key redacted]"; private static string EncodeBytes(ReadOnlySpan bytes) => Convert .ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); private static bool TryDecodeBytes(string value, out byte[] bytes) { bytes = []; if (string.IsNullOrEmpty(value) || value.Any(static character => character is not (>= 'A' and <= 'Z') and not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '-' and not '_')) { return false; } string padded = value.Replace('-', '+').Replace('_', '/'); padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" }; try { bytes = Convert.FromBase64String(padded); return true; } catch (FormatException) { return false; } } }