using System.Security.Cryptography; using System.Text.Json; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.State; namespace FinalFactory.Rendezvous.Server.Sessions; internal sealed record SessionLeaseTiming( int LeaseRenewAfterSeconds, int HostPresenceRefreshAfterSeconds) { public static SessionLeaseTiming From(EphemeralStoreOptions options) => new( Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)), Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2))); } internal sealed record SessionServiceResult(RendezvousErrorCode Error, T? Value = default) { public bool Succeeded => Error == RendezvousErrorCode.None; } internal sealed class SessionLeaseService( PublisherAuthorizationService authorization, IEphemeralRendezvousStore store, ISessionCapabilityService capabilities, SessionLeaseTiming timing, IWallClock clock) { public SessionServiceResult Register( AuthenticatedPrincipal principal, RegisterSessionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(principal); ArgumentNullException.ThrowIfNull(request); RendezvousErrorCode validation = ValidateRegistration(request); if (validation != RendezvousErrorCode.None) { return new(validation); } PublisherAuthorizationResult authorized = authorization.Authorize( principal, request.GameId, request.EnvironmentId, request.RegionId, request.ProtocolVersion, request.Visibility, request.Metadata, clock.UtcNow); if (!authorized.IsAllowed || authorized.Context is null) { return new(MapAuthorization(authorized.Error)); } AuthorizedPublisherContext context = authorized.Context; string requestFingerprint = ComputeRegistrationFingerprint(request); string derivationSalt = capabilities.CreateDerivationSalt(); string leaseToken = capabilities.DeriveCapability( "lease-token", context.Subject, request.IdempotencyKey, requestFingerprint, derivationSalt); string presenceCapability = capabilities.DeriveCapability( "host-presence", context.Subject, request.IdempotencyKey, requestFingerprint, derivationSalt); if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint) || !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint)) { throw new InvalidOperationException("Derived session capabilities could not be fingerprinted."); } SessionListingId listingId = new(capabilities.DeriveGuid( "listing-id", context.Subject, request.IdempotencyKey, requestFingerprint, derivationSalt)); LeaseId leaseId = new(capabilities.DeriveGuid( "lease-id", context.Subject, request.IdempotencyKey, requestFingerprint, derivationSalt)); MediationHandle presenceHandle = new(capabilities.DeriveGuid( "presence-handle", context.Subject, request.IdempotencyKey, requestFingerprint, derivationSalt)); int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted ? context.Policy.MaxAnonymousListingsPerAddress : context.Policy.MaxListingsPerPrincipal; if (ownerLimit <= 0) { return new(RendezvousErrorCode.CapacityExceeded); } StoreResult created = store.CreateListing(new( request.IdempotencyKey, requestFingerprint, new ListingDefinition { ListingId = listingId, LeaseId = leaseId, Scope = new(context.GameId, context.EnvironmentId), OwnerSubject = context.Subject, RegionId = context.RegionId, ProtocolVersion = context.ProtocolVersion, BuildVersion = request.BuildVersion, DisplayName = request.DisplayName, Visibility = context.Visibility, TrustMode = context.TrustMode, CurrentPlayers = request.Capacity.CurrentPlayers, MaximumPlayers = request.Capacity.MaximumPlayers, Metadata = request.Metadata, LeaseFingerprint = leaseFingerprint, HostPresenceHandle = presenceHandle, HostPresenceFingerprint = presenceFingerprint, CapabilityDerivationSalt = derivationSalt, }, ownerLimit), cancellationToken); if (!created.Succeeded || created.Value is null) { return new(MapStore(created.Code)); } ListingDefinition persisted = created.Value.Definition; leaseToken = capabilities.DeriveCapability( "lease-token", context.Subject, request.IdempotencyKey, requestFingerprint, persisted.CapabilityDerivationSalt); presenceCapability = capabilities.DeriveCapability( "host-presence", context.Subject, request.IdempotencyKey, requestFingerprint, persisted.CapabilityDerivationSalt); return new(RendezvousErrorCode.None, new RegisterSessionResponse { ListingId = persisted.ListingId, LeaseId = persisted.LeaseId, LeaseToken = leaseToken, HostPresenceHandle = persisted.HostPresenceHandle, HostPresenceCapability = presenceCapability, ExpiresAt = created.Value.LeaseExpiresAt, LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds, HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds, }); } public SessionServiceResult Renew( AuthenticatedPrincipal principal, SessionListingId listingId, RenewLeaseRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(principal); ArgumentNullException.ThrowIfNull(request); RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); if (validation != RendezvousErrorCode.None) { return new(validation); } RendezvousErrorCode lookup = GetAuthorizedListing( principal, listingId, request.LeaseToken, cancellationToken, out StoredListing? listing); if (lookup != RendezvousErrorCode.None) { return new(lookup); } StoredListing ownedListing = listing!; PublisherAuthorizationResult authorized = AuthorizeExisting( principal, ownedListing, ownedListing.Definition.Metadata); if (!authorized.IsAllowed) { return new(MapAuthorization(authorized.Error)); } capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); StoreResult renewed = store.RenewLease(new( listingId, ownedListing.Definition.LeaseId, fingerprint, ownedListing.Definition.OwnerSubject, ownedListing.Version), cancellationToken); return renewed.Succeeded && renewed.Value is not null ? new(RendezvousErrorCode.None, new RenewLeaseResponse { ExpiresAt = renewed.Value.LeaseExpiresAt, RenewAfterSeconds = timing.LeaseRenewAfterSeconds, }) : new(MapStore(renewed.Code)); } public SessionServiceResult Update( AuthenticatedPrincipal principal, SessionListingId listingId, UpdateSessionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(principal); ArgumentNullException.ThrowIfNull(request); RendezvousErrorCode validation = ValidateUpdate(request); if (validation != RendezvousErrorCode.None) { return new(validation); } RendezvousErrorCode lookup = GetAuthorizedListing( principal, listingId, request.LeaseToken, cancellationToken, out StoredListing? listing); if (lookup != RendezvousErrorCode.None) { return new(lookup); } StoredListing ownedListing = listing!; PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); if (!authorized.IsAllowed) { return new(MapAuthorization(authorized.Error)); } capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); StoreResult updated = store.UpdateListing(new( listingId, ownedListing.Definition.LeaseId, fingerprint, ownedListing.Definition.OwnerSubject, request.BuildVersion, request.DisplayName, request.Capacity.CurrentPlayers, request.Capacity.MaximumPlayers, request.Metadata), cancellationToken); return updated.Succeeded ? new(RendezvousErrorCode.None, true) : new(MapStore(updated.Code)); } public SessionServiceResult Delete( AuthenticatedPrincipal principal, SessionListingId listingId, DeleteSessionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(principal); ArgumentNullException.ThrowIfNull(request); RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); if (validation != RendezvousErrorCode.None) { return new(validation); } if (principal is not IPublisherPrincipal publisher || !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint)) { return new(RendezvousErrorCode.Forbidden); } StoreResult found = store.GetListing(listingId, false, cancellationToken); if (!found.Succeeded || found.Value is null) { return found.Code == StoreResultCode.ServiceUnavailable ? new(RendezvousErrorCode.ServiceUnavailable) : new(RendezvousErrorCode.None, true); } StoreResult deleted = store.DeleteListing(new( listingId, found.Value.Definition.LeaseId, fingerprint, publisher.Subject), cancellationToken); return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound ? new(RendezvousErrorCode.None, true) : new(MapStore(deleted.Code)); } private RendezvousErrorCode GetAuthorizedListing( AuthenticatedPrincipal principal, SessionListingId listingId, string leaseToken, CancellationToken cancellationToken, out StoredListing? listing) { listing = null; if (principal is not IPublisherPrincipal publisher) { return RendezvousErrorCode.Forbidden; } if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint)) { return RendezvousErrorCode.NotFound; } StoreResult found = store.GetListing(listingId, false, cancellationToken); if (!found.Succeeded || found.Value is null) { return MapStore(found.Code); } if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal) || found.Value.Definition.LeaseFingerprint != fingerprint) { return RendezvousErrorCode.NotFound; } listing = found.Value; return RendezvousErrorCode.None; } private PublisherAuthorizationResult AuthorizeExisting( AuthenticatedPrincipal principal, StoredListing listing, IReadOnlyDictionary metadata) => authorization.Authorize( principal, listing.Definition.Scope.GameId, listing.Definition.Scope.EnvironmentId, listing.Definition.RegionId, listing.Definition.ProtocolVersion, listing.Definition.Visibility, metadata, clock.UtcNow); private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest 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) || string.IsNullOrEmpty(request.RegionId.Value) || request.ProtocolVersion == 0 || !ContractValidation.IsBuildVersionValid(request.BuildVersion) || !ContractValidation.IsDisplayNameValid(request.DisplayName) || !Enum.IsDefined(request.Visibility) || !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsMetadataValid(request.Metadata) ? RendezvousErrorCode.InvalidRequest : RendezvousErrorCode.None; } private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request) { RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); if (lease != RendezvousErrorCode.None) { return lease; } return !ContractValidation.IsBuildVersionValid(request.BuildVersion) || !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsMetadataValid(request.Metadata) ? RendezvousErrorCode.InvalidRequest : RendezvousErrorCode.None; } private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken) { RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion); if (version != RendezvousErrorCode.None) { return version; } return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken) ? RendezvousErrorCode.None : RendezvousErrorCode.InvalidRequest; } private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch { PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired, PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol, PublisherAuthorizationError.RegionNotAllowed or PublisherAuthorizationError.VisibilityNotAllowed or PublisherAuthorizationError.AnonymousMustBeUnlisted or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest, _ => 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() { ContractVersion = request.ContractVersion, IdempotencyKey = request.IdempotencyKey, GameId = request.GameId, EnvironmentId = request.EnvironmentId, RegionId = request.RegionId, ProtocolVersion = request.ProtocolVersion, BuildVersion = request.BuildVersion, DisplayName = request.DisplayName, Visibility = request.Visibility, Capacity = new SessionCapacity { CurrentPlayers = request.Capacity.CurrentPlayers, MaximumPlayers = request.Capacity.MaximumPlayers, }, Metadata = request.Metadata .OrderBy(static item => item.Key, StringComparer.Ordinal) .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal), }; byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options); byte[] digest = SHA256.HashData(encoded); CryptographicOperations.ZeroMemory(encoded); try { return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } finally { CryptographicOperations.ZeroMemory(digest); } } }