328 lines
13 KiB
C#
328 lines
13 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);
|
|
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<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,
|
|
ExpiresAt = persisted.ExpiresAt,
|
|
});
|
|
}
|
|
|
|
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)
|
|
{
|
|
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<byte> bytes) => Convert
|
|
.ToBase64String(bytes)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|