From 1baa1055dca1efa56c9992979baec6d11640e088 Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 06:56:30 +0200 Subject: [PATCH] feat: implement scoped join attempts and tickets (#10) Closes #10 --- docs/api/rendezvous-v1.json | 129 ++++++- ...-state-privacy-availability-and-budgets.md | 2 +- .../0008-scoped-join-attempts-and-tickets.md | 66 ++++ docs/architecture/README.md | 1 + docs/contracts/http-v1.md | 6 + .../ConnectionTicketValidator.cs | 232 +++++++++++++ .../Properties/AssemblyInfo.cs | 3 + src/FinalFactory.Rendezvous.Client/README.md | 20 +- .../Browser/EphemeralCursorProtector.cs | 103 ++++++ .../Browser/SessionBrowserCursorCodec.cs | 90 +---- .../Http/ContractEndpoints.cs | 71 +++- .../JoinAttempts/JoinAttemptCursorCodec.cs | 96 +++++ .../JoinAttempts/JoinAttemptService.cs | 327 ++++++++++++++++++ src/FinalFactory.Rendezvous.Server/Program.cs | 3 + .../PrincipalCredentialService.cs | 11 +- .../Sessions/EphemeralCapabilityIssuer.cs | 14 + .../Sessions/SessionLeaseService.cs | 22 +- .../State/EphemeralStateContracts.cs | 47 ++- .../State/InMemoryEphemeralRendezvousStore.cs | 123 ++++++- .../State/StoreResultMapping.cs | 20 ++ .../Client/ConnectionTicketValidatorTests.cs | 102 ++++++ .../Contracts/OpenApiCompatibilityTests.cs | 19 + .../JoinAttemptHttpEndpointTests.cs | 204 +++++++++++ .../JoinAttempts/JoinAttemptServiceTests.cs | 286 +++++++++++++++ .../JoinAttempts/JoinAttemptTestData.cs | 117 +++++++ .../Provisioning/PrincipalCredentialTests.cs | 9 + .../Sessions/SessionLeaseServiceTests.cs | 1 + .../State/EphemeralStateTestData.cs | 2 + .../State/StoreResultMappingTests.cs | 30 ++ .../Contracts/v1/client-public-api.txt | 14 + 30 files changed, 2057 insertions(+), 113 deletions(-) create mode 100644 docs/architecture/0008-scoped-join-attempts-and-tickets.md create mode 100644 src/FinalFactory.Rendezvous.Client/ConnectionTickets/ConnectionTicketValidator.cs create mode 100644 src/FinalFactory.Rendezvous.Client/Properties/AssemblyInfo.cs create mode 100644 src/FinalFactory.Rendezvous.Server/Browser/EphemeralCursorProtector.cs create mode 100644 src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptCursorCodec.cs create mode 100644 src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs create mode 100644 src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Client/ConnectionTicketValidatorTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index d52ee83..bd9b7c5 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -686,8 +686,28 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -726,8 +746,109 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/v1/join-attempts/{attemptId}": { + "delete": { + "tags": [ + "Join attempts" + ], + "operationId": "CancelJoinAttempt", + "parameters": [ + { + "name": "attemptId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Rendezvous-Client-Punch-Capability", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { diff --git a/docs/architecture/0003-state-privacy-availability-and-budgets.md b/docs/architecture/0003-state-privacy-availability-and-budgets.md index 7338692..3aad737 100644 --- a/docs/architecture/0003-state-privacy-availability-and-budgets.md +++ b/docs/architecture/0003-state-privacy-availability-and-budgets.md @@ -64,7 +64,7 @@ them but must not raise them without security review. | Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort | | UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response | | Opaque HTTP credential | 1,024 bytes encoded | -| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes | +| UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes | | Clock skew | 30 seconds maximum when validating issued/not-before/expiry times | | Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension | | Host presence freshness | 20 seconds | diff --git a/docs/architecture/0008-scoped-join-attempts-and-tickets.md b/docs/architecture/0008-scoped-join-attempts-and-tickets.md new file mode 100644 index 0000000..aee26d5 --- /dev/null +++ b/docs/architecture/0008-scoped-join-attempts-and-tickets.md @@ -0,0 +1,66 @@ +# ADR 0008: scoped join attempts and one-time connection tickets + +- Status: Accepted +- Date: 2026-07-16 +- Tracking: #10 + +## Decision + +Join creation is an unauthenticated public operation because v1 does not treat a +Rendezvous caller as game identity. The HTTP source address is normalized and +converted to a process-keyed opaque subject for idempotency and bounded policy +accounting; raw addresses and the derived subject are never returned or logged. +A successful request means only that this network client may try to connect to +this active session. It does not reserve capacity or grant gameplay admission. + +Creation validates the v1 contract, caller idempotency key, enabled tenant policy, +exact gameplay protocol, listing scope, live lease, and fresh authenticated host +presence in one atomic store operation. A listing advertised as full remains +joinable because its player count is advisory and the game host owns the final +capacity, identity, ban, and admission decision. + +Each attempt derives independent host-punch, client-punch, and connection-ticket +credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC +key, the client subject, the complete canonical request fingerprint, a fresh salt, +and a purpose/role label. Credentials are 32-byte base64url values (43 characters), +below both the 192-character Rendezvous capability ceiling and LiteNetLib's +256-character NAT token ceiling. State retains keyed credential fingerprints, +derivation inputs, and salt—not issued plaintext. All diagnostic string +representations redact credentials and derivation material. + +The client receives only its punch capability. A host polls its own listing with +the lease token in `X-Rendezvous-Lease-Token` and receives only host-role +capabilities through a signed, listing-bound, five-minute cursor. Replaying an +identical join request returns the same live attempt; changing the request under +the same owner/key conflicts. A client may cancel with its punch capability in +`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the +attempt. Listing deletion, expiry, revocation, or process restart removes every +associated attempt and credential fingerprint. + +Endpoint binding remains role- and capability-specific. The first endpoint +observed for a role wins atomically; an exact UDP duplicate is idempotent, while +endpoint or role substitution is rejected. An introduction is consumable once +only after both roles bind, so concurrent attempts for the same listing cannot +cross-wire. + +The connection ticket is distinct from both punch capabilities and is reproduced +only after introduction succeeds. Its window begins at that moment and lasts at +most 20 seconds without outliving the 30-second attempt. The server has an atomic +fingerprint-consumption seam for mediator tests and revocation. On the game host, +the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest, +accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/ +revoked/replayed tickets, and zeroes retained digests and key material on disposal. +Issue #11 carries the ticket in the authenticated introduction; issue #12 wires +authorization and consumption into the caller-owned LiteNetLib coordinator. + +## Consequences + +- A join attempt is transport authorization, never proof of player identity or a + game slot. +- Network-address-derived subjects are process-local abuse/idempotency scopes, + not stable user identifiers; stronger authenticated player scopes require a + future game-owned identity contract. +- Cancellation after a ticket has reached a host must also revoke that host's + local validator entry; coordinator wiring owns that race in issue #12. +- Capability and ticket plaintext never enter browser results, state snapshots, + logs, metrics, or generated string representations. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 48e0ab7..7f708ac 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -10,6 +10,7 @@ decision requires a superseding ADR and corresponding contract/test updates. - [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md) - [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md) - [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md) +- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md) - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.md) - [Versioned HTTP and UDP contracts](../contracts/README.md) diff --git a/docs/contracts/http-v1.md b/docs/contracts/http-v1.md index 658374e..db97023 100644 --- a/docs/contracts/http-v1.md +++ b/docs/contracts/http-v1.md @@ -33,6 +33,7 @@ the same value as a required query parameter. | `GET` | `/v1/sessions` | Browse compatible public sessions. | | `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. | | `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. | +| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. | | `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. | | `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. | | `GET` | `/health/live` | Report that the HTTP process is alive. | @@ -49,6 +50,11 @@ for mutation operations are carried in their request bodies. Public browser responses contain no IP endpoints, lease tokens, punch capabilities, connection tickets, player identifiers, or gameplay state. +Attempt cancellation sends the short-lived client punch capability in +`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP +source only for a process-keyed, short-lived idempotency/abuse scope; this is not +player authentication and is never returned to callers. + ## Idempotency, cursors, and retries Registration and join creation require a caller-generated visible-ASCII diff --git a/src/FinalFactory.Rendezvous.Client/ConnectionTickets/ConnectionTicketValidator.cs b/src/FinalFactory.Rendezvous.Client/ConnectionTickets/ConnectionTicketValidator.cs new file mode 100644 index 0000000..64d255c --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/ConnectionTickets/ConnectionTicketValidator.cs @@ -0,0 +1,232 @@ +using System.Security.Cryptography; +using System.Text; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +public enum ConnectionTicketConsumptionResult +{ + Accepted = 1, + NotFound = 2, + Expired = 3, + Rejected = 4, + AlreadyConsumed = 5, + Revoked = 6, +} + +public sealed class ConnectionTicketValidator : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _tickets = []; + private readonly int _maximumAuthorizedTickets; + private readonly IConnectionTicketClock _clock; + private readonly byte[] _fingerprintKey = new byte[32]; + private bool _disposed; + + public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024) + : this(maximumAuthorizedTickets, new SystemConnectionTicketClock()) + { + } + + internal ConnectionTicketValidator( + int maximumAuthorizedTickets, + IConnectionTicketClock clock) + { + if (maximumAuthorizedTickets is < 1 or > 10_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets)); + } + + _maximumAuthorizedTickets = maximumAuthorizedTickets; + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + RandomNumberGenerator.Fill(_fingerprintKey); + } + + public bool TryAuthorize( + JoinAttemptId attemptId, + string connectionTicket, + DateTimeOffset expiresAt) + { + lock (_gate) + { + ThrowIfDisposed(); + DateTimeOffset now = _clock.UtcNow; + if (attemptId.Value == Guid.Empty + || !ContractValidation.IsConnectionTicketValid(connectionTicket) + || expiresAt <= now) + { + return false; + } + + RemoveExpired(now); + byte[] fingerprint = Fingerprint(connectionTicket); + if (_tickets.TryGetValue(attemptId, out TicketEntry? current)) + { + bool idempotent = current.State == TicketState.Active + && current.ExpiresAt == expiresAt + && CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint); + CryptographicOperations.ZeroMemory(fingerprint); + return idempotent; + } + + if (_tickets.Count >= _maximumAuthorizedTickets) + { + CryptographicOperations.ZeroMemory(fingerprint); + return false; + } + + _tickets.Add(attemptId, new(fingerprint, expiresAt)); + return true; + } + } + + public ConnectionTicketConsumptionResult Consume( + JoinAttemptId attemptId, + string connectionTicket) + { + lock (_gate) + { + ThrowIfDisposed(); + DateTimeOffset now = _clock.UtcNow; + if (attemptId.Value == Guid.Empty + || !ContractValidation.IsConnectionTicketValid(connectionTicket)) + { + return ConnectionTicketConsumptionResult.Rejected; + } + + if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry)) + { + RemoveExpired(now); + return ConnectionTicketConsumptionResult.NotFound; + } + + if (entry.ExpiresAt <= now) + { + Remove(attemptId, entry); + return ConnectionTicketConsumptionResult.Expired; + } + + if (entry.State == TicketState.Revoked) + { + return ConnectionTicketConsumptionResult.Revoked; + } + + if (entry.State == TicketState.Consumed) + { + return ConnectionTicketConsumptionResult.AlreadyConsumed; + } + + byte[] supplied = Fingerprint(connectionTicket); + bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied); + CryptographicOperations.ZeroMemory(supplied); + if (!matches) + { + return ConnectionTicketConsumptionResult.Rejected; + } + + entry.State = TicketState.Consumed; + return ConnectionTicketConsumptionResult.Accepted; + } + } + + public bool Revoke(JoinAttemptId attemptId) + { + lock (_gate) + { + ThrowIfDisposed(); + RemoveExpired(_clock.UtcNow); + if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry)) + { + return false; + } + + entry.State = TicketState.Revoked; + CryptographicOperations.ZeroMemory(entry.Fingerprint); + return true; + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + foreach (TicketEntry entry in _tickets.Values) + { + CryptographicOperations.ZeroMemory(entry.Fingerprint); + } + + _tickets.Clear(); + CryptographicOperations.ZeroMemory(_fingerprintKey); + _disposed = true; + } + } + + public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]"; + + private byte[] Fingerprint(string ticket) + { + byte[] encoded = Encoding.ASCII.GetBytes(ticket); + try + { + using HMACSHA256 hmac = new(_fingerprintKey); + return hmac.ComputeHash(encoded); + } + finally + { + CryptographicOperations.ZeroMemory(encoded); + } + } + + private void RemoveExpired(DateTimeOffset now) + { + foreach (KeyValuePair item in _tickets + .Where(item => item.Value.ExpiresAt <= now) + .ToArray()) + { + Remove(item.Key, item.Value); + } + } + + private void Remove(JoinAttemptId attemptId, TicketEntry entry) + { + CryptographicOperations.ZeroMemory(entry.Fingerprint); + _tickets.Remove(attemptId); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ConnectionTicketValidator)); + } + } + + private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt) + { + public byte[] Fingerprint { get; } = fingerprint; + public DateTimeOffset ExpiresAt { get; } = expiresAt; + public TicketState State { get; set; } + } + + private enum TicketState + { + Active = 0, + Consumed = 1, + Revoked = 2, + } +} + +internal interface IConnectionTicketClock +{ + DateTimeOffset UtcNow { get; } +} + +internal sealed class SystemConnectionTicketClock : IConnectionTicketClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/FinalFactory.Rendezvous.Client/Properties/AssemblyInfo.cs b/src/FinalFactory.Rendezvous.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..024def4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")] diff --git a/src/FinalFactory.Rendezvous.Client/README.md b/src/FinalFactory.Rendezvous.Client/README.md index bf523ff..ac3ff80 100644 --- a/src/FinalFactory.Rendezvous.Client/README.md +++ b/src/FinalFactory.Rendezvous.Client/README.md @@ -58,4 +58,22 @@ it when hosting stops. Use `IRendezvousPublisherClient` and `IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes the requests and responses it creates but never disposes the supplied `HttpClient`. -See the repository's ADR 0007 for retry, paging, ownership, and failure semantics. +The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate. +Authorize only tickets delivered by the authenticated Rendezvous introduction, +then consume the exact ticket presented by the direct LiteNetLib connection: + +```csharp +using ConnectionTicketValidator tickets = new(); +tickets.TryAuthorize(attemptId, expectedTicket, expiresAt); +ConnectionTicketConsumptionResult admission = tickets.Consume( + attemptId, + presentedTicket); +``` + +An `Accepted` ticket authorizes only this connection attempt. The game must still +apply its own player identity, capacity, ban, and gameplay admission rules. Revoke +the attempt on cancellation and dispose the validator during host shutdown so its +keyed ticket digests are zeroed. + +See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for +join-capability and connection-ticket security semantics. diff --git a/src/FinalFactory.Rendezvous.Server/Browser/EphemeralCursorProtector.cs b/src/FinalFactory.Rendezvous.Server/Browser/EphemeralCursorProtector.cs new file mode 100644 index 0000000..f7f7bfd --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Browser/EphemeralCursorProtector.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using System.Text; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Server.Browser; + +internal sealed class EphemeralCursorProtector : IDisposable +{ + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + private bool _disposed; + + public string Protect(string prefix, ReadOnlySpan payload) + { + ObjectDisposedException.ThrowIf(_disposed, this); + string content = $"{prefix}.{EncodeBytes(payload)}"; + byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content)); + try + { + string cursor = $"{content}.{EncodeBytes(signature)}"; + return ContractValidation.IsCursorValid(cursor) + ? cursor + : throw new InvalidOperationException("The protected cursor exceeds its contract limit."); + } + finally + { + CryptographicOperations.ZeroMemory(signature); + } + } + + public bool TryUnprotect(string prefix, string? cursor, out byte[] payload) + { + payload = []; + if (_disposed || !ContractValidation.IsCursorValid(cursor)) + { + return false; + } + + string[] segments = cursor!.Split('.'); + if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal)) + { + return false; + } + + byte[] expected = HMACSHA256.HashData( + _key, + Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + if (!TryDecodeBytes(segments[2], out byte[] supplied)) + { + CryptographicOperations.ZeroMemory(expected); + return false; + } + + bool validSignature = supplied.Length == expected.Length + && CryptographicOperations.FixedTimeEquals(supplied, expected); + CryptographicOperations.ZeroMemory(supplied); + CryptographicOperations.ZeroMemory(expected); + return validSignature && TryDecodeBytes(segments[1], out payload); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + CryptographicOperations.ZeroMemory(_key); + } + } + + public override string ToString() => "[EphemeralCursorProtector: key redacted]"; + + private static string EncodeBytes(ReadOnlySpan bytes) => Convert + .ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static bool TryDecodeBytes(string value, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(value) + || value.Any(static character => + character is not (>= 'A' and <= 'Z') + and not (>= 'a' and <= 'z') + and not (>= '0' and <= '9') + and not '-' + and not '_')) + { + return false; + } + + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" }; + try + { + bytes = Convert.FromBase64String(padded); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs index afd61d6..436099d 100644 --- a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs +++ b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using FinalFactory.Rendezvous.Contracts; @@ -10,12 +9,10 @@ namespace FinalFactory.Rendezvous.Server.Browser; internal sealed class SessionBrowserCursorCodec : IDisposable { private const string Prefix = "rvc1"; - private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); - private bool _disposed; + private readonly EphemeralCursorProtector _protector = new(); public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now) { - ObjectDisposedException.ThrowIf(_disposed, this); BrowserCursorPayload payload = new() { GameId = query.Scope.GameId.Value, @@ -26,19 +23,14 @@ internal sealed class SessionBrowserCursorCodec : IDisposable AfterListingId = after.ToString(), ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(), }; - string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options)); - string content = $"{Prefix}.{encoded}"; - byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content)); + byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options); try { - string cursor = $"{content}.{EncodeBytes(signature)}"; - return ContractValidation.IsCursorValid(cursor) - ? cursor - : throw new InvalidOperationException("The browser cursor exceeds its contract limit."); + return _protector.Protect(Prefix, encoded); } finally { - CryptographicOperations.ZeroMemory(signature); + CryptographicOperations.ZeroMemory(encoded); } } @@ -57,31 +49,7 @@ internal sealed class SessionBrowserCursorCodec : IDisposable return true; } - if (_disposed || !ContractValidation.IsCursorValid(cursor)) - { - return false; - } - - string[] segments = cursor.Split('.'); - if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal)) - { - return false; - } - - byte[] expected = HMACSHA256.HashData( - _key, - Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); - if (!TryDecodeBytes(segments[2], out byte[] supplied)) - { - CryptographicOperations.ZeroMemory(expected); - return false; - } - - bool validSignature = supplied.Length == expected.Length - && CryptographicOperations.FixedTimeEquals(supplied, expected); - CryptographicOperations.ZeroMemory(supplied); - CryptographicOperations.ZeroMemory(expected); - if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload)) + if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload)) { return false; } @@ -118,64 +86,30 @@ internal sealed class SessionBrowserCursorCodec : IDisposable return true; } - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - CryptographicOperations.ZeroMemory(_key); - } - } + public void Dispose() => _protector.Dispose(); public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]"; - - private static string EncodeBytes(ReadOnlySpan bytes) => Convert - .ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - - private static bool TryDecodeBytes(string value, out byte[] bytes) - { - bytes = []; - if (string.IsNullOrEmpty(value) - || value.Any(static character => - character is not (>= 'A' and <= 'Z') - and not (>= 'a' and <= 'z') - and not (>= '0' and <= '9') - and not '-' - and not '_')) - { - return false; - } - - string padded = value.Replace('-', '+').Replace('_', '/'); - padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" }; - try - { - bytes = Convert.FromBase64String(padded); - return true; - } - catch (FormatException) - { - return false; - } - } } internal sealed class BrowserCursorPayload { [JsonRequired] public string GameId { get; set; } = string.Empty; + [JsonRequired] public string EnvironmentId { get; set; } = string.Empty; + [JsonRequired] public uint ProtocolVersion { get; set; } + public string? RegionId { get; set; } + [JsonRequired] public bool ExcludeFull { get; set; } + [JsonRequired] public string AfterListingId { get; set; } = string.Empty; + [JsonRequired] public long ExpiresAtUnixSeconds { get; set; } } diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs index 4049ec9..fff66d6 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs @@ -1,5 +1,7 @@ +using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; @@ -67,7 +69,9 @@ internal static class ContractEndpoints .WithName("GetSession"); sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) .Produces() - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseHostJoinAttempts"); RouteGroupBuilder attempts = endpoints @@ -76,12 +80,22 @@ internal static class ContractEndpoints attempts.MapPost("/", CreateJoinAttempt) .Accepts("application/json") .Produces(StatusCodes.Status201Created) - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status429TooManyRequests) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CreateJoinAttempt"); + attempts.MapDelete("/{attemptId}", CancelJoinAttempt) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status503ServiceUnavailable) + .WithName("CancelJoinAttempt"); attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) .Accepts("application/json") .Produces() - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status501NotImplemented) .WithName("ReportConnectionOutcome"); return endpoints; @@ -268,10 +282,55 @@ internal static class ContractEndpoints [FromQuery] int contractVersion, [FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken, [FromQuery] int? pageSize, - [FromQuery] string? cursor) => NotImplemented(); + [FromQuery] string? cursor, + [FromServices] JoinAttemptService attempts, + CancellationToken cancellationToken) + { + JoinAttemptServiceResult result = attempts.BrowseForHost( + listingId, + contractVersion, + leaseToken, + pageSize ?? ContractLimits.BrowserPageMaxItems, + cursor, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } - private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) => - NotImplemented(); + private static IResult CreateJoinAttempt( + [FromBody] CreateJoinAttemptRequest request, + [FromServices] JoinAttemptService attempts, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress) + { + return Error(RendezvousErrorCode.InvalidRequest); + } + + string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); + JoinAttemptServiceResult result = attempts.Create( + clientSubject, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) + : Error(result.Error); + } + + private static IResult CancelJoinAttempt( + JoinAttemptId attemptId, + [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, + [FromServices] JoinAttemptService attempts, + CancellationToken cancellationToken) + { + JoinAttemptServiceResult result = attempts.Cancel( + attemptId, + clientPunchCapability, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } private static IResult ReportConnectionOutcome( JoinAttemptId attemptId, diff --git a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptCursorCodec.cs b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptCursorCodec.cs new file mode 100644 index 0000000..2625f0b --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptCursorCodec.cs @@ -0,0 +1,96 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; + +namespace FinalFactory.Rendezvous.Server.JoinAttempts; + +internal sealed class JoinAttemptCursorCodec : IDisposable +{ + private const string Prefix = "rvj1"; + private readonly EphemeralCursorProtector _protector = new(); + + public string Encode( + SessionListingId listingId, + JoinAttemptId after, + DateTimeOffset now) + { + JoinAttemptCursorPayload payload = new() + { + ListingId = listingId.ToString(), + AfterAttemptId = after.ToString(), + ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(), + }; + byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options); + try + { + return _protector.Protect(Prefix, encoded); + } + finally + { + CryptographicOperations.ZeroMemory(encoded); + } + } + + public bool TryDecode( + string? cursor, + SessionListingId listingId, + DateTimeOffset now, + out JoinAttemptId? after) + { + after = null; + if (cursor is null) + { + return true; + } + + if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload)) + { + return false; + } + + JoinAttemptCursorPayload? payload; + try + { + payload = JsonSerializer.Deserialize( + encodedPayload, + ContractJson.Options); + } + catch (JsonException) + { + payload = null; + } + finally + { + CryptographicOperations.ZeroMemory(encodedPayload); + } + + if (payload is null + || payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds() + || !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal) + || !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId)) + { + return false; + } + + after = attemptId; + return true; + } + + public void Dispose() => _protector.Dispose(); + + public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]"; +} + +internal sealed class JoinAttemptCursorPayload +{ + [JsonRequired] + public string ListingId { get; set; } = string.Empty; + + [JsonRequired] + public string AfterAttemptId { get; set; } = string.Empty; + + [JsonRequired] + public long ExpiresAtUnixSeconds { get; set; } +} diff --git a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs new file mode 100644 index 0000000..1cb042f --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs @@ -0,0 +1,327 @@ +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('/', '_'); +} diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index 3e66095..e596aa2 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -2,6 +2,7 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Http; +using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; @@ -122,6 +123,8 @@ else builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(new ProvisioningReadiness(true)); } diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs index b553d92..0825e06 100644 --- a/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs @@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature)) { - return CredentialValidationResult.Invalid(CredentialValidationError.Malformed); + return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid); } string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}"; @@ -441,7 +441,14 @@ internal static class Base64Url try { bytes = Convert.FromBase64String(padded); - return true; + if (string.Equals(Encode(bytes), value, StringComparison.Ordinal)) + { + return true; + } + + CryptographicOperations.ZeroMemory(bytes); + bytes = []; + return false; } catch (FormatException) { diff --git a/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs b/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs index 2f6783d..36b4549 100644 --- a/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs +++ b/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs @@ -20,6 +20,7 @@ internal interface ISessionCapabilityService string idempotencyKey, string requestFingerprint, string derivationSalt); + string DeriveOpaqueIdentifier(string purpose, string value); bool TryFingerprint(string? capability, out SecretFingerprint fingerprint); } @@ -91,6 +92,19 @@ internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDi } } + public string DeriveOpaqueIdentifier(string purpose, string value) + { + byte[] digest = Derive(purpose, value); + try + { + return Encode(digest); + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } + public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint) { fingerprint = default; diff --git a/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs index 979f456..fbfc99d 100644 --- a/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs +++ b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs @@ -127,7 +127,7 @@ internal sealed class SessionLeaseService( ownerLimit), cancellationToken); if (!created.Succeeded || created.Value is null) { - return new(MapStore(created.Code)); + return new(created.Code.ToContractError()); } ListingDefinition persisted = created.Value.Definition; @@ -205,7 +205,7 @@ internal sealed class SessionLeaseService( ExpiresAt = renewed.Value.LeaseExpiresAt, RenewAfterSeconds = timing.LeaseRenewAfterSeconds, }) - : new(MapStore(renewed.Code)); + : new(renewed.Code.ToContractError()); } public SessionServiceResult Update( @@ -253,7 +253,7 @@ internal sealed class SessionLeaseService( request.Metadata), cancellationToken); return updated.Succeeded ? new(RendezvousErrorCode.None, true) - : new(MapStore(updated.Code)); + : new(updated.Code.ToContractError()); } public SessionServiceResult Delete( @@ -291,7 +291,7 @@ internal sealed class SessionLeaseService( publisher.Subject), cancellationToken); return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound ? new(RendezvousErrorCode.None, true) - : new(MapStore(deleted.Code)); + : new(deleted.Code.ToContractError()); } private RendezvousErrorCode GetAuthorizedListing( @@ -315,7 +315,7 @@ internal sealed class SessionLeaseService( StoreResult found = store.GetListing(listingId, false, cancellationToken); if (!found.Succeeded || found.Value is null) { - return MapStore(found.Code); + return found.Code.ToContractError(); } if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal) @@ -403,18 +403,6 @@ internal sealed class SessionLeaseService( _ => 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() diff --git a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs index 9756d2b..ecc3e19 100644 --- a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs +++ b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs @@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60); public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20); public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30); + public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20); public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2); public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30); @@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime)); RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime)); RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime)); + RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime)); RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime)); RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime)); RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime)); + if (ConnectionTicketLifetime > JoinAttemptLifetime) + { + throw new ArgumentOutOfRangeException( + nameof(ConnectionTicketLifetime), + "Connection tickets cannot outlive their join attempt."); + } + if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime) { throw new ArgumentOutOfRangeException( @@ -246,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand public required uint ProtocolVersion { get; init; } public required SecretFingerprint HostCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; } + public required SecretFingerprint ConnectionTicketFingerprint { get; init; } + public required string CapabilityDerivationSalt { get; init; } public int ScopeAttemptLimit { get; init; } = int.MaxValue; + + public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]"; } internal sealed record AttemptEndpointBinding( @@ -261,12 +274,28 @@ internal sealed record StoredJoinAttempt public required SessionListingId ListingId { get; init; } public required string ClientSubject { get; init; } public required uint ProtocolVersion { get; init; } + public required string IdempotencyKey { get; init; } + public required string RequestFingerprint { get; init; } + public required string CapabilityDerivationSalt { get; init; } + public required SecretFingerprint HostCapabilityFingerprint { get; init; } + public required SecretFingerprint ClientCapabilityFingerprint { get; init; } + public required SecretFingerprint ConnectionTicketFingerprint { get; init; } public required DateTimeOffset ExpiresAt { get; init; } + public required DateTimeOffset ConnectionTicketExpiresAt { get; init; } public AttemptEndpointBinding? HostEndpoint { get; init; } public AttemptEndpointBinding? ClientEndpoint { get; init; } public required bool IntroductionConsumed { get; init; } + public required bool ConnectionTicketConsumed { get; init; } + + public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]"; } +internal sealed record HostJoinAttemptQuery( + SessionListingId ListingId, + SecretFingerprint LeaseFingerprint, + int MaximumResults, + JoinAttemptId? AfterAttemptId = null); + internal sealed record BindAttemptEndpointCommand( MediationHandle Handle, AttemptPeerRole Role, @@ -275,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand( ObservedEndpoint? LocalEndpoint); internal sealed record IntroductionEndpoints( - JoinAttemptId AttemptId, + StoredJoinAttempt Attempt, AttemptEndpointBinding Host, - AttemptEndpointBinding Client); + AttemptEndpointBinding Client) +{ + public JoinAttemptId AttemptId => Attempt.AttemptId; +} + +internal sealed record CancelJoinAttemptCommand( + JoinAttemptId AttemptId, + SecretFingerprint ClientCapabilityFingerprint); + +internal sealed record ConsumeConnectionTicketCommand( + JoinAttemptId AttemptId, + SecretFingerprint ConnectionTicketFingerprint); internal sealed record ReplayConsumption( string Namespace, @@ -316,8 +356,11 @@ internal interface IEphemeralRendezvousStore StoreResult> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default); StoreResult BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default); StoreResult CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default); + StoreResult> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default); + StoreResult CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default); StoreResult BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default); StoreResult ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default); + StoreResult ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default); StoreResult ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default); StoreResult RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default); StoreResult RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default); diff --git a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs index 4a193c9..0e2c337 100644 --- a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs +++ b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs @@ -390,6 +390,66 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return new(StoreResultCode.Success, Snapshot(attempt)); }, cancellationToken); + public StoreResult> BrowseHostJoinAttempts( + HostJoinAttemptQuery query, + CancellationToken cancellationToken = default) => Atomic>(_ => + { + ArgumentNullException.ThrowIfNull(query); + if (query.ListingId.Value == Guid.Empty + || !query.LeaseFingerprint.IsValid + || query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1) + { + throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query)); + } + + if (!_available) + { + return new(StoreResultCode.ServiceUnavailable); + } + + if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing) + || listing.Definition.LeaseFingerprint != query.LeaseFingerprint) + { + return new(StoreResultCode.NotFound); + } + + IReadOnlyList attempts = _attempts.Values + .Where(entry => entry.Command.ListingId == query.ListingId + && !entry.IntroductionConsumed + && (!query.AfterAttemptId.HasValue + || entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0)) + .OrderBy(static entry => entry.Command.AttemptId.Value) + .Take(query.MaximumResults) + .Select(Snapshot) + .ToArray(); + return new(StoreResultCode.Success, attempts); + }, cancellationToken); + + public StoreResult CancelJoinAttempt( + CancelJoinAttemptCommand command, + CancellationToken cancellationToken = default) => Atomic(_ => + { + ArgumentNullException.ThrowIfNull(command); + if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid) + { + throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command)); + } + + if (!_available) + { + return new(StoreResultCode.ServiceUnavailable); + } + + if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt) + || attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint) + { + return new(StoreResultCode.NotFound); + } + + RemoveAttempt(command.AttemptId); + return new(StoreResultCode.Success, true); + }, cancellationToken); + public StoreResult BindAttemptEndpoint( BindAttemptEndpointCommand command, CancellationToken cancellationToken = default) => Atomic(_ => @@ -447,7 +507,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto public StoreResult ConsumeIntroduction( MediationHandle handle, - CancellationToken cancellationToken = default) => Atomic(_ => + CancellationToken cancellationToken = default) => Atomic(now => { if (!_available) { @@ -471,12 +531,57 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto } attempt.IntroductionConsumed = true; + TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min( + _options.ConnectionTicketLifetime.Ticks, + (attempt.Deadline - now).Ticks)); + attempt.TicketDeadline = now + ticketLifetime; + attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime); return new(StoreResultCode.Success, new( - attempt.Command.AttemptId, + Snapshot(attempt), attempt.HostEndpoint, attempt.ClientEndpoint)); }, cancellationToken); + public StoreResult ConsumeConnectionTicket( + ConsumeConnectionTicketCommand command, + CancellationToken cancellationToken = default) => Atomic(now => + { + ArgumentNullException.ThrowIfNull(command); + if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid) + { + throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command)); + } + + if (!_available) + { + return new(StoreResultCode.ServiceUnavailable); + } + + if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt) + || attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint) + { + return new(StoreResultCode.NotFound); + } + + if (!attempt.IntroductionConsumed) + { + return new(StoreResultCode.Conflict); + } + + if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now) + { + return new(StoreResultCode.Expired); + } + + if (attempt.ConnectionTicketConsumed) + { + return new(StoreResultCode.ReplayRejected); + } + + attempt.ConnectionTicketConsumed = true; + return new(StoreResultCode.Success, true); + }, cancellationToken); + public StoreResult ConsumeReplay( ReplayConsumption consumption, CancellationToken cancellationToken = default) => Atomic(now => @@ -714,10 +819,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto ListingId = entry.Command.ListingId, ClientSubject = entry.Command.ClientSubject, ProtocolVersion = entry.Command.ProtocolVersion, + IdempotencyKey = entry.Command.IdempotencyKey, + RequestFingerprint = entry.Command.RequestFingerprint, + CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt, + HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint, + ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint, + ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint, ExpiresAt = entry.WallExpiresAt, + ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default, HostEndpoint = entry.HostEndpoint, ClientEndpoint = entry.ClientEndpoint, IntroductionConsumed = entry.IntroductionConsumed, + ConnectionTicketConsumed = entry.ConnectionTicketConsumed, }; private static void RemoveExpired(Dictionary entries, TimeSpan now) @@ -789,6 +902,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || command.ProtocolVersion == 0 || !command.HostCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid + || !command.ConnectionTicketFingerprint.IsValid + || !IsDerivationSaltValid(command.CapabilityDerivationSalt) || command.ScopeAttemptLimit <= 0) { throw new ArgumentException("Join attempt invariants are invalid.", nameof(command)); @@ -853,11 +968,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto public CreateJoinAttemptCommand Command { get; } = command; public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint; public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint; + public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint; public TimeSpan Deadline { get; } = deadline; public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt; + public TimeSpan? TicketDeadline { get; set; } + public DateTimeOffset? TicketWallExpiresAt { get; set; } public AttemptEndpointBinding? HostEndpoint { get; set; } public AttemptEndpointBinding? ClientEndpoint { get; set; } public bool IntroductionConsumed { get; set; } + public bool ConnectionTicketConsumed { get; set; } } private sealed record IdempotencyEntry( diff --git a/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs b/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs new file mode 100644 index 0000000..a9255ae --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs @@ -0,0 +1,20 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Server.State; + +internal static class StoreResultMapping +{ + public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch + { + StoreResultCode.Success => RendezvousErrorCode.None, + 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, + }; +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/ConnectionTicketValidatorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/ConnectionTicketValidatorTests.cs new file mode 100644 index 0000000..33ba78c --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/ConnectionTicketValidatorTests.cs @@ -0,0 +1,102 @@ +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Tests.Client; + +public sealed class ConnectionTicketValidatorTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void AuthorizedTicketIsAcceptedExactlyOnce() + { + ManualConnectionTicketClock clock = new(); + using ConnectionTicketValidator validator = new(1_024, clock); + JoinAttemptId attempt = NewAttempt(); + string ticket = Ticket('A'); + Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20))); + Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20))); + + Assert.Equal( + ConnectionTicketConsumptionResult.Accepted, + validator.Consume(attempt, ticket)); + Assert.Equal( + ConnectionTicketConsumptionResult.AlreadyConsumed, + validator.Consume(attempt, ticket)); + } + + [Fact] + public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected() + { + ManualConnectionTicketClock clock = new(); + using ConnectionTicketValidator validator = new(1_024, clock); + JoinAttemptId first = NewAttempt(); + JoinAttemptId second = NewAttempt(); + Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20))); + Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40))); + + Assert.Equal( + ConnectionTicketConsumptionResult.Rejected, + validator.Consume(first, Ticket('B'))); + clock.Advance(TimeSpan.FromSeconds(20)); + Assert.Equal( + ConnectionTicketConsumptionResult.Expired, + validator.Consume(first, Ticket('A'))); + Assert.True(validator.Revoke(second)); + Assert.Equal( + ConnectionTicketConsumptionResult.Revoked, + validator.Consume(second, Ticket('B'))); + } + + [Fact] + public async Task ConcurrentConsumptionHasOneWinner() + { + ManualConnectionTicketClock clock = new(); + using ConnectionTicketValidator validator = new(1_024, clock); + JoinAttemptId attempt = NewAttempt(); + string ticket = Ticket('C'); + Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20))); + using ManualResetEventSlim start = new(false); + Task left = Task.Run(() => + { + start.Wait(); + return validator.Consume(attempt, ticket); + }); + Task right = Task.Run(() => + { + start.Wait(); + return validator.Consume(attempt, ticket); + }); + + start.Set(); + ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right); + + Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted); + Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed); + } + + [Fact] + public void ValidatorIsBoundedDisposableAndRedacted() + { + ManualConnectionTicketClock clock = new(); + ConnectionTicketValidator validator = new(1, clock); + Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20))); + Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20))); + Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal); + + validator.Dispose(); + + Assert.Throws(() => validator.Revoke(NewAttempt())); + Assert.Throws(() => validator.Consume(default, string.Empty)); + } + + private static JoinAttemptId NewAttempt() => new(Guid.NewGuid()); + private static string Ticket(char value) => new(value, 43); + + private sealed class ManualConnectionTicketClock : IConnectionTicketClock + { + public DateTimeOffset UtcNow { get; set; } = Now; + + public void Advance(TimeSpan duration) => UtcNow += duration; + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs index 0c09eb1..ff3c785 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs @@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests "/health/live", "/health/ready", "/v1/join-attempts", + "/v1/join-attempts/{attemptId}", "/v1/join-attempts/{attemptId}/outcome", "/v1/sessions", "/v1/sessions/{listingId}", @@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests .GetProperty("security"); Assert.True(security[0].TryGetProperty("PublisherBearer", out _)); } + + JsonElement cancelParameters = root.GetProperty("paths") + .GetProperty("/v1/join-attempts/{attemptId}") + .GetProperty("delete") + .GetProperty("parameters"); + JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter => + parameter.GetProperty("in").GetString() == "header" + && parameter.GetProperty("name").GetString() + == "X-Rendezvous-Client-Punch-Capability"); + Assert.True(cancelCapability.GetProperty("required").GetBoolean()); + JsonElement hostPollParameters = root.GetProperty("paths") + .GetProperty("/v1/sessions/{listingId}/join-attempts") + .GetProperty("get") + .GetProperty("parameters"); + JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter => + parameter.GetProperty("in").GetString() == "header" + && parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token"); + Assert.True(leaseToken.GetProperty("required").GetBoolean()); } } diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs new file mode 100644 index 0000000..79e701b --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http.Json; +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.Http; +using FinalFactory.Rendezvous.Server.JoinAttempts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; +using FinalFactory.Rendezvous.Tests.State; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace FinalFactory.Rendezvous.Tests.JoinAttempts; + +public sealed class JoinAttemptHttpEndpointTests +{ + [Fact] + public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp() + { + await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync(); + RendezvousPublisherClient publisher = new(host.HttpClient); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistration(), + host.PublisherCredential)); + Assert.True(host.Capabilities.TryFingerprint( + session.HostPresenceCapability, + out SecretFingerprint presenceFingerprint)); + Assert.True(host.Store.BindHostPresence(new( + session.HostPresenceHandle, + presenceFingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000), + null)).Succeeded); + CreateJoinAttemptRequest request = new() + { + IdempotencyKey = "http-join-1", + GameId = new("space-game"), + EnvironmentId = new("production"), + ListingId = session.ListingId, + ProtocolVersion = 7, + }; + + using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync( + "v1/join-attempts", + request, + ContractJson.Options); + Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode); + CreateJoinAttemptResponse created = Assert.IsType( + await createdResponse.Content.ReadFromJsonAsync(ContractJson.Options)); + + using HttpRequestMessage pollRequest = new( + HttpMethod.Get, + $"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10"); + pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken); + using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest); + Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode); + BrowseHostJoinAttemptsResponse polled = Assert.IsType( + await pollResponse.Content.ReadFromJsonAsync(ContractJson.Options)); + HostJoinAttempt hostAttempt = Assert.Single(polled.Items); + Assert.Equal(created.AttemptId, hostAttempt.AttemptId); + Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability); + + using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync( + $"v1/join-attempts/{created.AttemptId}"); + Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode); + ApiError missingCapabilityError = Assert.IsType( + await missingCapability.Content.ReadFromJsonAsync(ContractJson.Options)); + Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code); + + using HttpRequestMessage unauthorizedCancel = new( + HttpMethod.Delete, + $"v1/join-attempts/{created.AttemptId}"); + unauthorizedCancel.Headers.Add( + "X-Rendezvous-Client-Punch-Capability", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel); + Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode); + + using HttpRequestMessage cancelRequest = new( + HttpMethod.Delete, + $"v1/join-attempts/{created.AttemptId}"); + cancelRequest.Headers.Add( + "X-Rendezvous-Client-Punch-Capability", + created.ClientPunchCapability); + using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest); + Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode); + + using HttpRequestMessage emptyPollRequest = new( + HttpMethod.Get, + $"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10"); + emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken); + using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest); + BrowseHostJoinAttemptsResponse empty = Assert.IsType( + await emptyPollResponse.Content.ReadFromJsonAsync(ContractJson.Options)); + Assert.Empty(empty.Items); + } + + private static T AssertSuccess(RendezvousClientResult result) + { + Assert.True(result.IsSuccess, result.Message); + return Assert.IsAssignableFrom(result.Value); + } + + private static RegisterSessionRequest CreateRegistration() => new() + { + IdempotencyKey = "join-http-host", + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.0.0", + DisplayName = "Join HTTP host", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 }, + Metadata = new() { ["mode"] = "online-coop" }, + }; + + private sealed class JoinHttpTestHost : IAsyncDisposable + { + private readonly WebApplication _application; + + private JoinHttpTestHost( + WebApplication application, + HttpClient httpClient, + InMemoryEphemeralRendezvousStore store, + EphemeralCapabilityIssuer capabilities, + string publisherCredential) + { + _application = application; + HttpClient = httpClient; + Store = store; + Capabilities = capabilities; + PublisherCredential = publisherCredential; + } + + internal HttpClient HttpClient { get; } + internal InMemoryEphemeralRendezvousStore Store { get; } + internal EphemeralCapabilityIssuer Capabilities { get; } + internal string PublisherCredential { get; } + + internal static async Task StartAsync() + { + ManualRendezvousClock clock = new(ProvisioningTestData.Now); + EphemeralStoreOptions stateOptions = new(); + InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); + EphemeralCapabilityIssuer capabilities = new(); + ProvisioningRuntime provisioning = ProvisioningRuntime.Create( + ProvisioningTestData.CreateOptions(), + ProvisioningTestData.CreateSecrets("secret-1"), + clock.UtcNow); + DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(); + string credential = provisioning.Credentials.Issue(principal, clock.UtcNow); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(static options => + ContractJson.Configure(options.SerializerOptions)); + builder.Services.Configure(static options => + options.ThrowOnBadRequest = true); + builder.Services.AddProblemDetails(); + builder.Services.AddExceptionHandler(); + builder.Services.AddSingleton(provisioning); + builder.Services.AddSingleton(provisioning.Policies); + builder.Services.AddSingleton(provisioning.Credentials); + builder.Services.AddSingleton(provisioning.PublisherAuthorization); + builder.Services.AddSingleton(store); + builder.Services.AddSingleton(clock); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + WebApplication app = builder.Build(); + app.UseExceptionHandler(); + app.MapRendezvousContractEndpoints(); + await app.StartAsync(); + IServer server = app.Services.GetRequiredService(); + string address = Assert.Single(server.Features.Get()!.Addresses); + return new( + app, + new HttpClient { BaseAddress = new Uri(address) }, + store, + capabilities, + credential); + } + + public async ValueTask DisposeAsync() + { + HttpClient.Dispose(); + await _application.StopAsync(); + await _application.DisposeAsync(); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs new file mode 100644 index 0000000..1f99177 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs @@ -0,0 +1,286 @@ +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.JoinAttempts; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Tests.JoinAttempts; + +public sealed class JoinAttemptServiceTests +{ + [Fact] + public void CreateIsIdempotentAndScopesDistinctRoleCredentials() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key"); + + JoinAttemptServiceResult first = fixture.Service.Create( + fixture.ClientSubject, + request); + JoinAttemptServiceResult replay = fixture.Service.Create( + fixture.ClientSubject, + request); + + Assert.True(first.Succeeded); + Assert.True(replay.Succeeded); + Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId); + Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle); + Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability); + Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability)); + Assert.InRange( + first.Value.ClientPunchCapability.Length, + 1, + ContractLimits.LiteNetLibNatTokenMaxCharacters); + + HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + 10, + null).Value!.Items); + Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability); + Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void SameIdempotencyKeyWithDifferentRequestConflicts() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + (RegisterSessionResponse other, _) = fixture.CreateHost(); + CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key"); + Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded); + + request.ListingId = other.ListingId; + JoinAttemptServiceResult conflict = fixture.Service.Create( + fixture.ClientSubject, + request); + + Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error); + } + + [Fact] + public void CreationRejectsStaleIncompatibleAndCrossTenantListings() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false); + Assert.Equal( + RendezvousErrorCode.NotFound, + fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error); + + (RegisterSessionResponse active, _) = fixture.CreateHost(); + CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId); + incompatible.ProtocolVersion = 8; + Assert.Equal( + RendezvousErrorCode.IncompatibleProtocol, + fixture.Service.Create(fixture.ClientSubject, incompatible).Error); + + CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId); + otherTenant.GameId = new("other-game"); + Assert.Equal( + RendezvousErrorCode.NotFound, + fixture.Service.Create(fixture.ClientSubject, otherTenant).Error); + } + + [Fact] + public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + fixture.Create(registration.ListingId); + fixture.Create(registration.ListingId); + fixture.Create(registration.ListingId); + + JoinAttemptServiceResult first = fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + 1, + null); + Assert.True(first.Succeeded); + Assert.Single(first.Value!.Items); + Assert.NotNull(first.Value.NextCursor); + + JoinAttemptServiceResult second = fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + 1, + first.Value.NextCursor); + Assert.True(second.Succeeded); + Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId); + + Assert.Equal( + RendezvousErrorCode.NotFound, + fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + 1, + null).Error); + Assert.Equal( + RendezvousErrorCode.InvalidRequest, + fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + 1, + first.Value.NextCursor + "x").Error); + + (RegisterSessionResponse other, _) = fixture.CreateHost(); + Assert.Equal( + RendezvousErrorCode.InvalidRequest, + fixture.Service.BrowseForHost( + other.ListingId, + ContractLimits.ContractVersion, + other.LeaseToken, + 1, + first.Value.NextCursor).Error); + } + + [Fact] + public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse created = fixture.Create(registration.ListingId); + + Assert.Equal( + RendezvousErrorCode.NotFound, + fixture.Service.Cancel( + created.AttemptId, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error); + Assert.True(fixture.Service.Cancel( + created.AttemptId, + created.ClientPunchCapability).Succeeded); + Assert.Empty(fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + 10, + null).Value!.Items); + } + + [Fact] + public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse first = fixture.Create(registration.ListingId); + CreateJoinAttemptResponse second = fixture.Create(registration.ListingId); + StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId); + Assert.True(fixture.Sessions.Capabilities.TryFingerprint( + second.ClientPunchCapability, + out SecretFingerprint secondClientFingerprint)); + Assert.True(fixture.Sessions.Capabilities.TryFingerprint( + first.ClientPunchCapability, + out SecretFingerprint firstClientFingerprint)); + + Assert.Equal( + StoreResultCode.NotFound, + fixture.Sessions.Store.BindAttemptEndpoint(new( + firstStored.MediationHandle, + AttemptPeerRole.Client, + secondClientFingerprint, + new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000), + null)).Code); + Assert.Equal( + StoreResultCode.NotFound, + fixture.Sessions.Store.BindAttemptEndpoint(new( + firstStored.MediationHandle, + AttemptPeerRole.Host, + firstClientFingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000), + null)).Code); + } + + [Fact] + public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse created = fixture.Create(registration.ListingId); + IntroductionEndpoints introduction = fixture.Introduce(registration, created); + JoinAttemptServiceResult issued = fixture.Service.IssueConnectionTicket( + introduction.Attempt); + Assert.True(issued.Succeeded); + Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket)); + Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket); + Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal); + Assert.True(fixture.Sessions.Capabilities.TryFingerprint( + issued.Value.Ticket, + out SecretFingerprint ticketFingerprint)); + ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint); + using ManualResetEventSlim start = new(false); + + Task> left = Task.Run(() => + { + start.Wait(); + return fixture.Sessions.Store.ConsumeConnectionTicket(command); + }); + Task> right = Task.Run(() => + { + start.Wait(); + return fixture.Sessions.Store.ConsumeConnectionTicket(command); + }); + start.Set(); + StoreResult[] results = await Task.WhenAll(left, right); + + Assert.Single(results, static result => result.Succeeded); + Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected); + } + + [Fact] + public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry() + { + EphemeralStoreOptions options = new() + { + ConnectionTicketLifetime = TimeSpan.FromSeconds(5), + }; + using JoinAttemptFixture fixture = new(options); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse first = fixture.Create(registration.ListingId); + CreateJoinAttemptResponse second = fixture.Create(registration.ListingId); + StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId); + StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId); + + Assert.Equal( + StoreResultCode.Conflict, + fixture.Sessions.Store.ConsumeConnectionTicket(new( + first.AttemptId, + firstStored.ConnectionTicketFingerprint)).Code); + Assert.Equal( + StoreResultCode.NotFound, + fixture.Sessions.Store.ConsumeConnectionTicket(new( + second.AttemptId, + firstStored.ConnectionTicketFingerprint)).Code); + + fixture.Introduce(registration, first); + fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime); + Assert.Equal( + StoreResultCode.Expired, + fixture.Sessions.Store.ConsumeConnectionTicket(new( + first.AttemptId, + firstStored.ConnectionTicketFingerprint)).Code); + Assert.False(secondStored.ConnectionTicketConsumed); + } + + [Fact] + public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse created = fixture.Create(registration.ListingId); + fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15)); + + IntroductionEndpoints introduction = fixture.Introduce(registration, created); + ConnectionTicketGrant ticket = Assert.IsType( + fixture.Service.IssueConnectionTicket(introduction.Attempt).Value); + + Assert.Equal(created.ExpiresAt, ticket.ExpiresAt); + Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow); + Assert.DoesNotContain( + introduction.Attempt.CapabilityDerivationSalt, + introduction.Attempt.ToString(), + StringComparison.Ordinal); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs new file mode 100644 index 0000000..a1fe8e8 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs @@ -0,0 +1,117 @@ +using System.Net; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.JoinAttempts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; +using FinalFactory.Rendezvous.Tests.Sessions; + +namespace FinalFactory.Rendezvous.Tests.JoinAttempts; + +internal sealed class JoinAttemptFixture : IDisposable +{ + private int _sequence; + + public JoinAttemptFixture(EphemeralStoreOptions? options = null) + { + Sessions = new(options); + Cursors = new(); + GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); + Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock); + ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40")); + } + + public SessionLeaseFixture Sessions { get; } + public JoinAttemptCursorCodec Cursors { get; } + public JoinAttemptService Service { get; } + public string ClientSubject { get; } + + public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true) + { + RegisterSessionResponse registration = Sessions.Register(); + if (bindPresence) + { + Assert.True(Sessions.BindPresence(registration).Succeeded); + } + + StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!; + return (registration, listing); + } + + public CreateJoinAttemptRequest Request( + SessionListingId listingId, + string? idempotencyKey = null) => new() + { + IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}", + GameId = Sessions.Scope.GameId, + EnvironmentId = Sessions.Scope.EnvironmentId, + ListingId = listingId, + ProtocolVersion = 7, + }; + + public CreateJoinAttemptResponse Create( + SessionListingId listingId, + string? idempotencyKey = null) + { + JoinAttemptServiceResult result = Service.Create( + ClientSubject, + Request(listingId, idempotencyKey)); + Assert.True(result.Succeeded); + return Assert.IsType(result.Value); + } + + public StoredJoinAttempt GetAttempt( + RegisterSessionResponse registration, + JoinAttemptId attemptId) + { + Assert.True(Sessions.Capabilities.TryFingerprint( + registration.LeaseToken, + out SecretFingerprint leaseFingerprint)); + IReadOnlyList attempts = Sessions.Store.BrowseHostJoinAttempts(new( + registration.ListingId, + leaseFingerprint, + ContractLimits.BrowserPageMaxItems)).Value!; + return attempts.Single(attempt => attempt.AttemptId == attemptId); + } + + public IntroductionEndpoints Introduce( + RegisterSessionResponse registration, + CreateJoinAttemptResponse created) + { + StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId); + HostJoinAttempt host = Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + ContractLimits.BrowserPageMaxItems, + null).Value!.Items.Single(item => item.AttemptId == created.AttemptId); + Assert.True(Sessions.Capabilities.TryFingerprint( + host.HostPunchCapability, + out SecretFingerprint hostFingerprint)); + Assert.True(Sessions.Capabilities.TryFingerprint( + created.ClientPunchCapability, + out SecretFingerprint clientFingerprint)); + Assert.True(Sessions.Store.BindAttemptEndpoint(new( + attempt.MediationHandle, + AttemptPeerRole.Host, + hostFingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000), + null)).Succeeded); + Assert.True(Sessions.Store.BindAttemptEndpoint(new( + attempt.MediationHandle, + AttemptPeerRole.Client, + clientFingerprint, + new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000), + null)).Succeeded); + StoreResult introduced = Sessions.Store.ConsumeIntroduction( + attempt.MediationHandle); + Assert.True(introduced.Succeeded); + return introduced.Value!; + } + + public void Dispose() + { + Cursors.Dispose(); + Sessions.Dispose(); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs index a0e4b29..376f229 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs @@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning; public sealed class PrincipalCredentialTests { + [Fact] + public void Base64UrlDecoderRejectsNonCanonicalTrailingBits() + { + Assert.True(Base64Url.TryDecode("AA", out byte[] canonical)); + Assert.Equal(new byte[] { 0 }, canonical); + Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical)); + Assert.Empty(nonCanonical); + } + [Fact] public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals() { diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs index 65c6e2f..661a2e8 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs @@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests { LeaseLifetime = TimeSpan.FromSeconds(5), JoinAttemptLifetime = TimeSpan.FromSeconds(5), + ConnectionTicketLifetime = TimeSpan.FromSeconds(5), IdempotencyLifetime = TimeSpan.FromSeconds(6), }; using SessionLeaseFixture fixture = new(options); diff --git a/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs b/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs index 7a9a44e..523edf6 100644 --- a/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs +++ b/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs @@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture ProtocolVersion = listing.Definition.ProtocolVersion, HostCapabilityFingerprint = Fingerprint($"host-{sequence}"), ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"), + ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"), + CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", }; } diff --git a/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs b/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs new file mode 100644 index 0000000..395e4fb --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs @@ -0,0 +1,30 @@ +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Tests.State; + +public sealed class StoreResultMappingTests +{ + [Fact] + public void EveryStoreResultHasOneSharedContractErrorMapping() + { + Dictionary expected = new() + { + [StoreResultCode.Success] = RendezvousErrorCode.None, + [StoreResultCode.NotFound] = RendezvousErrorCode.NotFound, + [StoreResultCode.Expired] = RendezvousErrorCode.Expired, + [StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden, + [StoreResultCode.Conflict] = RendezvousErrorCode.Conflict, + [StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded, + [StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable, + [StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected, + [StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable, + }; + + Assert.Equal(Enum.GetValues().Length, expected.Count); + foreach (KeyValuePair item in expected) + { + Assert.Equal(item.Value, item.Key.ToContractError()); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt index b9d75e8..5a9d25b 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt @@ -1,3 +1,17 @@ +TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult + ENUM Accepted=1 + ENUM NotFound=2 + ENUM Expired=3 + ENUM Rejected=4 + ENUM AlreadyConsumed=5 + ENUM Revoked=6 +TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator + CTOR (System.Int32 maximumAuthorizedTickets) + METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket) + METHOD System.Void Dispose() + METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId) + METHOD System.String ToString() + METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt) TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken) TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient