Files
Rendezvous/src/FinalFactory.Rendezvous.Contracts/Udp/NatIntroductionTokenCodec.cs
T
KyuubiYoru b4b6072fe1
quality-gate / quality (push) Successful in 56s
feat(client): add rendezvous traversal coordinators (#12)
2026-07-16 08:39:05 +02:00

150 lines
4.5 KiB
C#

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('/', '_');
}