feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s

Closes #10
This commit is contained in:
KyuubiYoru
2026-07-16 06:56:30 +02:00
parent 06c3973ce7
commit 1baa1055dc
30 changed files with 2057 additions and 113 deletions
@@ -1,5 +1,4 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
@@ -10,12 +9,10 @@ namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable
{
private const string Prefix = "rvc1";
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
private readonly EphemeralCursorProtector _protector = new();
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BrowserCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
@@ -26,19 +23,14 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string content = $"{Prefix}.{encoded}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
try
{
string cursor = $"{content}.{EncodeBytes(signature)}";
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
return _protector.Protect(Prefix, encoded);
}
finally
{
CryptographicOperations.ZeroMemory(signature);
CryptographicOperations.ZeroMemory(encoded);
}
}
@@ -57,31 +49,7 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true;
}
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);
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
return false;
}
@@ -118,64 +86,30 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
public void Dispose() => _protector.Dispose();
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors 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;
}
}
}
internal sealed class BrowserCursorPayload
{
[JsonRequired]
public string GameId { get; set; } = string.Empty;
[JsonRequired]
public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired]
public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; }
[JsonRequired]
public bool ExcludeFull { get; set; }
[JsonRequired]
public string AfterListingId { get; set; } = string.Empty;
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}