feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
quality-gate / quality (push) Successful in 55s
Closes #7
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user