using System.Security.Cryptography; using System.Text; namespace FinalFactory.Rendezvous.Contracts; public sealed class NatIntroductionToken { public JoinAttemptId AttemptId { get; set; } public string ConnectionTicket { get; set; } = string.Empty; public override string ToString() => $"[NatIntroductionToken {AttemptId}; ticket redacted]"; } public static class NatIntroductionTokenCodec { public const int EncodedLength = ContractLimits.DerivedCredentialCharacters; private const int DecodedLength = 32; private const int AttemptIdLength = 16; private const int AuthenticatorLength = DecodedLength - AttemptIdLength; public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator) { if (attemptId.Value == Guid.Empty || !ContractValidation.IsConnectionTicketValid(derivedAuthenticator) || !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator) || authenticator.Length != DecodedLength) { throw new ArgumentException("The NAT introduction token fields are invalid."); } byte[] payload = new byte[DecodedLength]; try { if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength))) { throw new InvalidOperationException("The join attempt identifier could not be encoded."); } authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength)); return EncodeBase64Url(payload); } finally { CryptographicOperations.ZeroMemory(authenticator); CryptographicOperations.ZeroMemory(payload); } } public static bool TryDecode(string? encoded, out NatIntroductionToken? token) { token = null; if (!ContractValidation.IsConnectionTicketValid(encoded) || !TryDecodeBase64Url(encoded!, out byte[]? payload) || payload.Length != DecodedLength) { return false; } try { Guid attemptId = new(payload.AsSpan(0, AttemptIdLength)); if (attemptId == Guid.Empty) { return false; } token = new NatIntroductionToken { AttemptId = new JoinAttemptId(attemptId), ConnectionTicket = encoded!, }; return true; } finally { CryptographicOperations.ZeroMemory(payload); } } public static string ComputeDigest(string connectionTicket) { if (!ContractValidation.IsConnectionTicketValid(connectionTicket)) { throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket)); } byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket); byte[] digest; using (SHA256 sha256 = SHA256.Create()) { digest = sha256.ComputeHash(encoded); } CryptographicOperations.ZeroMemory(encoded); try { return EncodeBase64Url(digest); } finally { CryptographicOperations.ZeroMemory(digest); } } public static bool MatchesDigest(string? connectionTicket, string? expectedDigest) { if (!ContractValidation.IsConnectionTicketValid(connectionTicket) || !ContractValidation.IsConnectionTicketValid(expectedDigest)) { return false; } byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!)); byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!); try { return CryptographicOperations.FixedTimeEquals(actual, expected); } finally { CryptographicOperations.ZeroMemory(actual); CryptographicOperations.ZeroMemory(expected); } } private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes) { bytes = []; if (encoded is null || encoded.Length != EncodedLength) { return false; } try { bytes = Convert.FromBase64String( encoded.Replace('-', '+').Replace('_', '/') + "="); return true; } catch (FormatException) { return false; } } private static string EncodeBase64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }