feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
quality-gate / quality (push) Successful in 1m1s
Closes #10
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
@@ -67,7 +69,9 @@ internal static class ContractEndpoints
|
||||
.WithName("GetSession");
|
||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseHostJoinAttempts");
|
||||
|
||||
RouteGroupBuilder attempts = endpoints
|
||||
@@ -76,12 +80,22 @@ internal static class ContractEndpoints
|
||||
attempts.MapPost("/", CreateJoinAttempt)
|
||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CreateJoinAttempt");
|
||||
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CancelJoinAttempt");
|
||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||
.Produces<ReportConnectionOutcomeResponse>()
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
|
||||
.WithName("ReportConnectionOutcome");
|
||||
|
||||
return endpoints;
|
||||
@@ -268,10 +282,55 @@ internal static class ContractEndpoints
|
||||
[FromQuery] int contractVersion,
|
||||
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor) => NotImplemented();
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
|
||||
NotImplemented();
|
||||
private static IResult CreateJoinAttempt(
|
||||
[FromBody] CreateJoinAttemptRequest request,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult CancelJoinAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult ReportConnectionOutcome(
|
||||
JoinAttemptId attemptId,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
|
||||
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvj1";
|
||||
private readonly EphemeralCursorProtector _protector = new();
|
||||
|
||||
public string Encode(
|
||||
SessionListingId listingId,
|
||||
JoinAttemptId after,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
JoinAttemptCursorPayload payload = new()
|
||||
{
|
||||
ListingId = listingId.ToString(),
|
||||
AfterAttemptId = after.ToString(),
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||
try
|
||||
{
|
||||
return _protector.Protect(Prefix, encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
SessionListingId listingId,
|
||||
DateTimeOffset now,
|
||||
out JoinAttemptId? after)
|
||||
{
|
||||
after = null;
|
||||
if (cursor is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JoinAttemptCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
after = attemptId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _protector.Dispose();
|
||||
|
||||
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||
}
|
||||
|
||||
internal sealed class JoinAttemptCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string ListingId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string AfterAttemptId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
|
||||
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||
}
|
||||
|
||||
internal sealed class JoinAttemptService(
|
||||
GamePolicyRegistry policies,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||
? remoteAddress.MapToIPv4()
|
||||
: remoteAddress;
|
||||
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||
string clientSubject,
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||
{
|
||||
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||
}
|
||||
|
||||
RendezvousErrorCode validation = ValidateCreate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||
|| policy is null)
|
||||
{
|
||||
return new(RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||
{
|
||||
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||
}
|
||||
|
||||
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||
{
|
||||
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||
}
|
||||
|
||||
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||
"join-attempt-id",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||
"join-mediation-handle",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||
{
|
||||
IdempotencyOwner = clientSubject,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
RequestFingerprint = requestFingerprint,
|
||||
ClientSubject = clientSubject,
|
||||
AttemptId = attemptId,
|
||||
MediationHandle = mediationHandle,
|
||||
Scope = new(request.GameId, request.EnvironmentId),
|
||||
ListingId = request.ListingId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
HostCapabilityFingerprint = hostFingerprint,
|
||||
ClientCapabilityFingerprint = clientFingerprint,
|
||||
ConnectionTicketFingerprint = ticketFingerprint,
|
||||
CapabilityDerivationSalt = derivationSalt,
|
||||
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||
}, cancellationToken);
|
||||
if (!created.Succeeded || created.Value is null)
|
||||
{
|
||||
return new(created.Code.ToContractError());
|
||||
}
|
||||
|
||||
StoredJoinAttempt persisted = created.Value;
|
||||
clientCapability = Derive(
|
||||
"join-client-punch",
|
||||
persisted.ClientSubject,
|
||||
persisted.IdempotencyKey,
|
||||
persisted.RequestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||
}
|
||||
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||
{
|
||||
AttemptId = persisted.AttemptId,
|
||||
MediationHandle = persisted.MediationHandle,
|
||||
ClientPunchCapability = clientCapability,
|
||||
ExpiresAt = persisted.ExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||
SessionListingId listingId,
|
||||
int contractVersion,
|
||||
string? leaseToken,
|
||||
int pageSize,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(version);
|
||||
}
|
||||
|
||||
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||
|| !ContractValidation.IsCursorValid(cursor)
|
||||
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||
listingId,
|
||||
leaseFingerprint,
|
||||
pageSize + 1,
|
||||
after), cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code.ToContractError());
|
||||
}
|
||||
|
||||
bool hasMore = found.Value.Count > pageSize;
|
||||
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||
BrowseHostJoinAttemptsResponse response = new()
|
||||
{
|
||||
Items = page.Select(CreateHostAttempt).ToList(),
|
||||
NextCursor = hasMore && page.Length > 0
|
||||
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||
: null,
|
||||
};
|
||||
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||
? new(RendezvousErrorCode.None, response)
|
||||
: new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<bool> Cancel(
|
||||
JoinAttemptId attemptId,
|
||||
string? clientPunchCapability,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||
return cancelled.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(cancelled.Code.ToContractError());
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||
StoredJoinAttempt attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
if (!attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(RendezvousErrorCode.Conflict);
|
||||
}
|
||||
|
||||
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||
{
|
||||
return new(RendezvousErrorCode.Expired);
|
||||
}
|
||||
|
||||
string ticket = Derive(
|
||||
"connection-ticket",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt);
|
||||
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||
}
|
||||
|
||||
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||
{
|
||||
string capability = Derive(
|
||||
"join-host-punch",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt);
|
||||
if (!ContractValidation.IsCapabilityValid(capability)
|
||||
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
MediationHandle = attempt.MediationHandle,
|
||||
HostPunchCapability = capability,
|
||||
ExpiresAt = attempt.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ListingId.Value == Guid.Empty
|
||||
|| request.ProtocolVersion == 0
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||
{
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||
byte[] digest = SHA256.HashData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
private string Derive(
|
||||
string purpose,
|
||||
string clientSubject,
|
||||
CreateJoinAttemptRequest request,
|
||||
string requestFingerprint,
|
||||
string derivationSalt) => Derive(
|
||||
purpose,
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
|
||||
private string Derive(
|
||||
string purpose,
|
||||
string clientSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt) => capabilities.DeriveCapability(
|
||||
purpose,
|
||||
clientSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
|
||||
private static bool CredentialLengthsAreValid(
|
||||
string hostCapability,
|
||||
string clientCapability,
|
||||
string ticket) =>
|
||||
ContractValidation.IsCapabilityValid(hostCapability)
|
||||
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||
|
||||
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
@@ -122,6 +123,8 @@ else
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
|
||||
|
||||
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
||||
{
|
||||
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
|
||||
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
|
||||
}
|
||||
|
||||
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
||||
@@ -441,7 +441,14 @@ internal static class Base64Url
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(padded);
|
||||
return true;
|
||||
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
bytes = [];
|
||||
return false;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ internal interface ISessionCapabilityService
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt);
|
||||
string DeriveOpaqueIdentifier(string purpose, string value);
|
||||
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||
}
|
||||
|
||||
@@ -91,6 +92,19 @@ internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDi
|
||||
}
|
||||
}
|
||||
|
||||
public string DeriveOpaqueIdentifier(string purpose, string value)
|
||||
{
|
||||
byte[] digest = Derive(purpose, value);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||
{
|
||||
fingerprint = default;
|
||||
|
||||
@@ -127,7 +127,7 @@ internal sealed class SessionLeaseService(
|
||||
ownerLimit), cancellationToken);
|
||||
if (!created.Succeeded || created.Value is null)
|
||||
{
|
||||
return new(MapStore(created.Code));
|
||||
return new(created.Code.ToContractError());
|
||||
}
|
||||
|
||||
ListingDefinition persisted = created.Value.Definition;
|
||||
@@ -205,7 +205,7 @@ internal sealed class SessionLeaseService(
|
||||
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||
})
|
||||
: new(MapStore(renewed.Code));
|
||||
: new(renewed.Code.ToContractError());
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Update(
|
||||
@@ -253,7 +253,7 @@ internal sealed class SessionLeaseService(
|
||||
request.Metadata), cancellationToken);
|
||||
return updated.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(MapStore(updated.Code));
|
||||
: new(updated.Code.ToContractError());
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Delete(
|
||||
@@ -291,7 +291,7 @@ internal sealed class SessionLeaseService(
|
||||
publisher.Subject), cancellationToken);
|
||||
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(MapStore(deleted.Code));
|
||||
: new(deleted.Code.ToContractError());
|
||||
}
|
||||
|
||||
private RendezvousErrorCode GetAuthorizedListing(
|
||||
@@ -315,7 +315,7 @@ internal sealed class SessionLeaseService(
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return MapStore(found.Code);
|
||||
return found.Code.ToContractError();
|
||||
}
|
||||
|
||||
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||
@@ -403,18 +403,6 @@ internal sealed class SessionLeaseService(
|
||||
_ => RendezvousErrorCode.Forbidden,
|
||||
};
|
||||
|
||||
private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch
|
||||
{
|
||||
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
};
|
||||
|
||||
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||
{
|
||||
RegisterSessionRequest canonical = new()
|
||||
|
||||
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
|
||||
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
@@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions
|
||||
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(ConnectionTicketLifetime),
|
||||
"Connection tickets cannot outlive their join attempt.");
|
||||
}
|
||||
|
||||
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
@@ -246,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
|
||||
public required uint ProtocolVersion { get; init; }
|
||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||
|
||||
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||
}
|
||||
|
||||
internal sealed record AttemptEndpointBinding(
|
||||
@@ -261,12 +274,28 @@ internal sealed record StoredJoinAttempt
|
||||
public required SessionListingId ListingId { get; init; }
|
||||
public required string ClientSubject { get; init; }
|
||||
public required uint ProtocolVersion { get; init; }
|
||||
public required string IdempotencyKey { get; init; }
|
||||
public required string RequestFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||
public required bool IntroductionConsumed { get; init; }
|
||||
public required bool ConnectionTicketConsumed { get; init; }
|
||||
|
||||
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||
}
|
||||
|
||||
internal sealed record HostJoinAttemptQuery(
|
||||
SessionListingId ListingId,
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
int MaximumResults,
|
||||
JoinAttemptId? AfterAttemptId = null);
|
||||
|
||||
internal sealed record BindAttemptEndpointCommand(
|
||||
MediationHandle Handle,
|
||||
AttemptPeerRole Role,
|
||||
@@ -275,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand(
|
||||
ObservedEndpoint? LocalEndpoint);
|
||||
|
||||
internal sealed record IntroductionEndpoints(
|
||||
JoinAttemptId AttemptId,
|
||||
StoredJoinAttempt Attempt,
|
||||
AttemptEndpointBinding Host,
|
||||
AttemptEndpointBinding Client);
|
||||
AttemptEndpointBinding Client)
|
||||
{
|
||||
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||
}
|
||||
|
||||
internal sealed record CancelJoinAttemptCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ClientCapabilityFingerprint);
|
||||
|
||||
internal sealed record ConsumeConnectionTicketCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ConnectionTicketFingerprint);
|
||||
|
||||
internal sealed record ReplayConsumption(
|
||||
string Namespace,
|
||||
@@ -316,8 +356,11 @@ internal interface IEphemeralRendezvousStore
|
||||
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -390,6 +390,66 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
|
||||
HostJoinAttemptQuery query,
|
||||
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (query.ListingId.Value == Guid.Empty
|
||||
|| !query.LeaseFingerprint.IsValid
|
||||
|| query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1)
|
||||
{
|
||||
throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing)
|
||||
|| listing.Definition.LeaseFingerprint != query.LeaseFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||
&& !entry.IntroductionConsumed
|
||||
&& (!query.AfterAttemptId.HasValue
|
||||
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||
.Take(query.MaximumResults)
|
||||
.Select(Snapshot)
|
||||
.ToArray();
|
||||
return new(StoreResultCode.Success, attempts);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> CancelJoinAttempt(
|
||||
CancelJoinAttemptCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
RemoveAttempt(command.AttemptId);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
||||
BindAttemptEndpointCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
||||
@@ -447,7 +507,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
||||
MediationHandle handle,
|
||||
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ =>
|
||||
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
|
||||
{
|
||||
if (!_available)
|
||||
{
|
||||
@@ -471,12 +531,57 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
attempt.IntroductionConsumed = true;
|
||||
TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min(
|
||||
_options.ConnectionTicketLifetime.Ticks,
|
||||
(attempt.Deadline - now).Ticks));
|
||||
attempt.TicketDeadline = now + ticketLifetime;
|
||||
attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime);
|
||||
return new(StoreResultCode.Success, new(
|
||||
attempt.Command.AttemptId,
|
||||
Snapshot(attempt),
|
||||
attempt.HostEndpoint,
|
||||
attempt.ClientEndpoint));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> ConsumeConnectionTicket(
|
||||
ConsumeConnectionTicketCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||
|| attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (!attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||
{
|
||||
return new(StoreResultCode.Expired);
|
||||
}
|
||||
|
||||
if (attempt.ConnectionTicketConsumed)
|
||||
{
|
||||
return new(StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
attempt.ConnectionTicketConsumed = true;
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> ConsumeReplay(
|
||||
ReplayConsumption consumption,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||
@@ -714,10 +819,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
ListingId = entry.Command.ListingId,
|
||||
ClientSubject = entry.Command.ClientSubject,
|
||||
ProtocolVersion = entry.Command.ProtocolVersion,
|
||||
IdempotencyKey = entry.Command.IdempotencyKey,
|
||||
RequestFingerprint = entry.Command.RequestFingerprint,
|
||||
CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt,
|
||||
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
|
||||
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||
ExpiresAt = entry.WallExpiresAt,
|
||||
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||
HostEndpoint = entry.HostEndpoint,
|
||||
ClientEndpoint = entry.ClientEndpoint,
|
||||
IntroductionConsumed = entry.IntroductionConsumed,
|
||||
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
||||
};
|
||||
|
||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
||||
@@ -789,6 +902,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| command.ProtocolVersion == 0
|
||||
|| !command.HostCapabilityFingerprint.IsValid
|
||||
|| !command.ClientCapabilityFingerprint.IsValid
|
||||
|| !command.ConnectionTicketFingerprint.IsValid
|
||||
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|
||||
|| command.ScopeAttemptLimit <= 0)
|
||||
{
|
||||
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
||||
@@ -853,11 +968,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
public CreateJoinAttemptCommand Command { get; } = command;
|
||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||
public TimeSpan Deadline { get; } = deadline;
|
||||
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
||||
public TimeSpan? TicketDeadline { get; set; }
|
||||
public DateTimeOffset? TicketWallExpiresAt { get; set; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||
public bool IntroductionConsumed { get; set; }
|
||||
public bool ConnectionTicketConsumed { get; set; }
|
||||
}
|
||||
|
||||
private sealed record IdempotencyEntry(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
internal static class StoreResultMapping
|
||||
{
|
||||
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||
{
|
||||
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user