150 lines
4.5 KiB
C#
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('/', '_');
|
|
}
|