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(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 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 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 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> 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 Cancel( JoinAttemptId attemptId, string? clientPunchCapability, CancellationToken cancellationToken = default) { if (!ContractValidation.IsCapabilityValid(clientPunchCapability) || !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint)) { return new(RendezvousErrorCode.InvalidRequest); } StoreResult cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken); return cancelled.Succeeded ? new(RendezvousErrorCode.None, true) : new(cancelled.Code.ToContractError()); } public JoinAttemptServiceResult 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 bytes) => Convert .ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); }