104 lines
3.2 KiB
C#
104 lines
3.2 KiB
C#
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<byte> 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<byte> 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;
|
|
}
|
|
}
|
|
}
|