Files
Rendezvous/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs
T
KyuubiYoru 94aba8a3bb
quality-gate / quality (push) Successful in 59s
feat(client): standardize connection outcomes (#13)
2026-07-16 10:18:41 +02:00

344 lines
14 KiB
C#

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);
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
string connectionTicket = NatIntroductionTokenCodec.Encode(
attemptId,
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.");
}
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,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(persisted)),
ExpiresAt = persisted.ExpiresAt,
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
});
}
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 || attempt.IsCancelled)
{
return new(RendezvousErrorCode.Conflict);
}
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
{
return new(RendezvousErrorCode.Expired);
}
string ticket = CreateConnectionTicket(attempt);
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,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(attempt)),
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
}
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt));
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.DerivedCredentialCharacters
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}