Files
Rendezvous/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs
T
KyuubiYoru 49564c7e7e
quality-gate / quality (push) Successful in 55s
feat: add presence-gated session leases (#7)
Closes #7
2026-07-16 05:58:47 +02:00

453 lines
18 KiB
C#

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<T>(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<RegisterSessionResponse> 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<StoredListing> 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<RenewLeaseResponse> 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<StoredListing> 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<bool> 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<StoredListing> 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<bool> 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<StoredListing> 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<bool> 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<StoredListing> 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<string, string> 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);
}
}
}