feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s

Closes #10
This commit is contained in:
KyuubiYoru
2026-07-16 06:56:30 +02:00
parent 06c3973ce7
commit 1baa1055dc
30 changed files with 2057 additions and 113 deletions
+125 -4
View File
@@ -686,8 +686,28 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "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": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -726,8 +746,109 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "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": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -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 | | 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 | | UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
| Opaque HTTP credential | 1,024 bytes encoded | | 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 | | 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 | | Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
| Host presence freshness | 20 seconds | | Host presence freshness | 20 seconds |
@@ -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.
+1
View File
@@ -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 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.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 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) - [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md) - [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md) - [Versioned HTTP and UDP contracts](../contracts/README.md)
+6
View File
@@ -33,6 +33,7 @@ the same value as a required query parameter.
| `GET` | `/v1/sessions` | Browse compatible public sessions. | | `GET` | `/v1/sessions` | Browse compatible public sessions. |
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. | | `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. | | `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. | | `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. | | `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `GET` | `/health/live` | Report that the HTTP process is alive. | | `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 responses contain no IP endpoints, lease tokens, punch capabilities, connection
tickets, player identifiers, or gameplay state. 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 ## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII Registration and join creation require a caller-generated visible-ASCII
@@ -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<JoinAttemptId, TicketEntry> _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<JoinAttemptId, TicketEntry> 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;
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
+19 -1
View File
@@ -58,4 +58,22 @@ it when hosting stops. Use `IRendezvousPublisherClient` and
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes `IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
the requests and responses it creates but never disposes the supplied `HttpClient`. 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.
@@ -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<byte> 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<byte> 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;
}
}
}
@@ -1,5 +1,4 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
@@ -10,12 +9,10 @@ namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable internal sealed class SessionBrowserCursorCodec : IDisposable
{ {
private const string Prefix = "rvc1"; private const string Prefix = "rvc1";
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); private readonly EphemeralCursorProtector _protector = new();
private bool _disposed;
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now) public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
BrowserCursorPayload payload = new() BrowserCursorPayload payload = new()
{ {
GameId = query.Scope.GameId.Value, GameId = query.Scope.GameId.Value,
@@ -26,19 +23,14 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
AfterListingId = after.ToString(), AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(), ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
}; };
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options)); byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
string content = $"{Prefix}.{encoded}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
try try
{ {
string cursor = $"{content}.{EncodeBytes(signature)}"; return _protector.Protect(Prefix, encoded);
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
} }
finally finally
{ {
CryptographicOperations.ZeroMemory(signature); CryptographicOperations.ZeroMemory(encoded);
} }
} }
@@ -57,31 +49,7 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true; return true;
} }
if (_disposed || !ContractValidation.IsCursorValid(cursor)) if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
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))
{ {
return false; return false;
} }
@@ -118,64 +86,30 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true; return true;
} }
public void Dispose() public void Dispose() => _protector.Dispose();
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]"; public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
private static string EncodeBytes(ReadOnlySpan<byte> 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 internal sealed class BrowserCursorPayload
{ {
[JsonRequired] [JsonRequired]
public string GameId { get; set; } = string.Empty; public string GameId { get; set; } = string.Empty;
[JsonRequired] [JsonRequired]
public string EnvironmentId { get; set; } = string.Empty; public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired] [JsonRequired]
public uint ProtocolVersion { get; set; } public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; } public string? RegionId { get; set; }
[JsonRequired] [JsonRequired]
public bool ExcludeFull { get; set; } public bool ExcludeFull { get; set; }
[JsonRequired] [JsonRequired]
public string AfterListingId { get; set; } = string.Empty; public string AfterListingId { get; set; } = string.Empty;
[JsonRequired] [JsonRequired]
public long ExpiresAtUnixSeconds { get; set; } public long ExpiresAtUnixSeconds { get; set; }
} }
@@ -1,5 +1,7 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
@@ -67,7 +69,9 @@ internal static class ContractEndpoints
.WithName("GetSession"); .WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>() .Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts"); .WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints RouteGroupBuilder attempts = endpoints
@@ -76,12 +80,22 @@ internal static class ContractEndpoints
attempts.MapPost("/", CreateJoinAttempt) attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json") .Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created) .Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt"); .WithName("CreateJoinAttempt");
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json") .Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>() .Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status501NotImplemented)
.WithName("ReportConnectionOutcome"); .WithName("ReportConnectionOutcome");
return endpoints; return endpoints;
@@ -268,10 +282,55 @@ internal static class ContractEndpoints
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken, [FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented(); [FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> 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) => private static IResult CreateJoinAttempt(
NotImplemented(); [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<CreateJoinAttemptResponse> 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<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult ReportConnectionOutcome( private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId, JoinAttemptId attemptId,
@@ -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<JoinAttemptCursorPayload>(
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; }
}
@@ -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<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
{
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
}
internal sealed class JoinAttemptService(
GamePolicyRegistry policies,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptCursorCodec cursors,
IWallClock clock)
{
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
{
ArgumentNullException.ThrowIfNull(remoteAddress);
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
? remoteAddress.MapToIPv4()
: remoteAddress;
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
}
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
string clientSubject,
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(clientSubject))
{
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
}
RendezvousErrorCode validation = ValidateCreate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return new(RendezvousErrorCode.NotFound);
}
if (!policy.AllowsProtocol(request.ProtocolVersion))
{
return new(RendezvousErrorCode.IncompatibleProtocol);
}
string requestFingerprint = ComputeRequestFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
{
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
}
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
"join-mediation-handle",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
{
IdempotencyOwner = clientSubject,
IdempotencyKey = request.IdempotencyKey,
RequestFingerprint = requestFingerprint,
ClientSubject = clientSubject,
AttemptId = attemptId,
MediationHandle = mediationHandle,
Scope = new(request.GameId, request.EnvironmentId),
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
HostCapabilityFingerprint = hostFingerprint,
ClientCapabilityFingerprint = clientFingerprint,
ConnectionTicketFingerprint = ticketFingerprint,
CapabilityDerivationSalt = derivationSalt,
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
}, cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(created.Code.ToContractError());
}
StoredJoinAttempt persisted = created.Value;
clientCapability = Derive(
"join-client-punch",
persisted.ClientSubject,
persisted.IdempotencyKey,
persisted.RequestFingerprint,
persisted.CapabilityDerivationSalt);
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
}
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
{
AttemptId = persisted.AttemptId,
MediationHandle = persisted.MediationHandle,
ClientPunchCapability = clientCapability,
ExpiresAt = persisted.ExpiresAt,
});
}
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
SessionListingId listingId,
int contractVersion,
string? leaseToken,
int pageSize,
string? cursor,
CancellationToken cancellationToken = default)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
if (version != RendezvousErrorCode.None)
{
return new(version);
}
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|| !ContractValidation.IsPageSizeValid(pageSize)
|| !ContractValidation.IsCursorValid(cursor)
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
listingId,
leaseFingerprint,
pageSize + 1,
after), cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code.ToContractError());
}
bool hasMore = found.Value.Count > pageSize;
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
BrowseHostJoinAttemptsResponse response = new()
{
Items = page.Select(CreateHostAttempt).ToList(),
NextCursor = hasMore && page.Length > 0
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
: null,
};
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
? new(RendezvousErrorCode.None, response)
: new(RendezvousErrorCode.CapacityExceeded);
}
public JoinAttemptServiceResult<bool> Cancel(
JoinAttemptId attemptId,
string? clientPunchCapability,
CancellationToken cancellationToken = default)
{
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
return cancelled.Succeeded
? new(RendezvousErrorCode.None, true)
: new(cancelled.Code.ToContractError());
}
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
StoredJoinAttempt attempt)
{
ArgumentNullException.ThrowIfNull(attempt);
if (!attempt.IntroductionConsumed)
{
return new(RendezvousErrorCode.Conflict);
}
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
{
return new(RendezvousErrorCode.Expired);
}
string ticket = Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsConnectionTicketValid(ticket)
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|| fingerprint != attempt.ConnectionTicketFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
}
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
}
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
{
string capability = Derive(
"join-host-punch",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsCapabilityValid(capability)
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|| fingerprint != attempt.HostCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
}
return new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = capability,
ExpiresAt = attempt.ExpiresAt,
};
}
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|| string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ListingId.Value == Guid.Empty
|| request.ProtocolVersion == 0
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
{
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
CryptographicOperations.ZeroMemory(encoded);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
private string Derive(
string purpose,
string clientSubject,
CreateJoinAttemptRequest request,
string requestFingerprint,
string derivationSalt) => Derive(
purpose,
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
private string Derive(
string purpose,
string clientSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt) => capabilities.DeriveCapability(
purpose,
clientSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
private static bool CredentialLengthsAreValid(
string hostCapability,
string clientCapability,
string ticket) =>
ContractValidation.IsCapabilityValid(hostCapability)
&& ContractValidation.IsCapabilityValid(clientCapability)
&& ContractValidation.IsConnectionTicketValid(ticket)
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
@@ -2,6 +2,7 @@ using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
@@ -122,6 +123,8 @@ else
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true)); builder.Services.AddSingleton(new ProvisioningReadiness(true));
} }
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature)) 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]}"; string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
@@ -441,7 +441,14 @@ internal static class Base64Url
try try
{ {
bytes = Convert.FromBase64String(padded); bytes = Convert.FromBase64String(padded);
return true; if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
{
return true;
}
CryptographicOperations.ZeroMemory(bytes);
bytes = [];
return false;
} }
catch (FormatException) catch (FormatException)
{ {
@@ -20,6 +20,7 @@ internal interface ISessionCapabilityService
string idempotencyKey, string idempotencyKey,
string requestFingerprint, string requestFingerprint,
string derivationSalt); string derivationSalt);
string DeriveOpaqueIdentifier(string purpose, string value);
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint); 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) public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
{ {
fingerprint = default; fingerprint = default;
@@ -127,7 +127,7 @@ internal sealed class SessionLeaseService(
ownerLimit), cancellationToken); ownerLimit), cancellationToken);
if (!created.Succeeded || created.Value is null) if (!created.Succeeded || created.Value is null)
{ {
return new(MapStore(created.Code)); return new(created.Code.ToContractError());
} }
ListingDefinition persisted = created.Value.Definition; ListingDefinition persisted = created.Value.Definition;
@@ -205,7 +205,7 @@ internal sealed class SessionLeaseService(
ExpiresAt = renewed.Value.LeaseExpiresAt, ExpiresAt = renewed.Value.LeaseExpiresAt,
RenewAfterSeconds = timing.LeaseRenewAfterSeconds, RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
}) })
: new(MapStore(renewed.Code)); : new(renewed.Code.ToContractError());
} }
public SessionServiceResult<bool> Update( public SessionServiceResult<bool> Update(
@@ -253,7 +253,7 @@ internal sealed class SessionLeaseService(
request.Metadata), cancellationToken); request.Metadata), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(MapStore(updated.Code)); : new(updated.Code.ToContractError());
} }
public SessionServiceResult<bool> Delete( public SessionServiceResult<bool> Delete(
@@ -291,7 +291,7 @@ internal sealed class SessionLeaseService(
publisher.Subject), cancellationToken); publisher.Subject), cancellationToken);
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(MapStore(deleted.Code)); : new(deleted.Code.ToContractError());
} }
private RendezvousErrorCode GetAuthorizedListing( private RendezvousErrorCode GetAuthorizedListing(
@@ -315,7 +315,7 @@ internal sealed class SessionLeaseService(
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken); StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null) 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) if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
@@ -403,18 +403,6 @@ internal sealed class SessionLeaseService(
_ => RendezvousErrorCode.Forbidden, _ => 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) private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
{ {
RegisterSessionRequest canonical = new() RegisterSessionRequest canonical = new()
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60); public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20); public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30); 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 ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2); public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
@@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime)); RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime)); RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime)); RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime)); RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime)); RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime)); 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) if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
{ {
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
@@ -246,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
public required uint ProtocolVersion { get; init; } public required uint ProtocolVersion { get; init; }
public required SecretFingerprint HostCapabilityFingerprint { get; init; } public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { 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 int ScopeAttemptLimit { get; init; } = int.MaxValue;
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
} }
internal sealed record AttemptEndpointBinding( internal sealed record AttemptEndpointBinding(
@@ -261,12 +274,28 @@ internal sealed record StoredJoinAttempt
public required SessionListingId ListingId { get; init; } public required SessionListingId ListingId { get; init; }
public required string ClientSubject { get; init; } public required string ClientSubject { get; init; }
public required uint ProtocolVersion { 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 ExpiresAt { get; init; }
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; } public AttemptEndpointBinding? HostEndpoint { get; init; }
public AttemptEndpointBinding? ClientEndpoint { get; init; } public AttemptEndpointBinding? ClientEndpoint { get; init; }
public required bool IntroductionConsumed { 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( internal sealed record BindAttemptEndpointCommand(
MediationHandle Handle, MediationHandle Handle,
AttemptPeerRole Role, AttemptPeerRole Role,
@@ -275,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand(
ObservedEndpoint? LocalEndpoint); ObservedEndpoint? LocalEndpoint);
internal sealed record IntroductionEndpoints( internal sealed record IntroductionEndpoints(
JoinAttemptId AttemptId, StoredJoinAttempt Attempt,
AttemptEndpointBinding Host, 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( internal sealed record ReplayConsumption(
string Namespace, string Namespace,
@@ -316,8 +356,11 @@ internal interface IEphemeralRendezvousStore
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default); StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default); StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default); StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default); StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default); StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default); StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default); StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default); StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
@@ -390,6 +390,66 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Success, Snapshot(attempt)); return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken); }, cancellationToken);
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
HostJoinAttemptQuery query,
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
{
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<StoredJoinAttempt> 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<bool> CancelJoinAttempt(
CancelJoinAttemptCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
{
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<StoredJoinAttempt> BindAttemptEndpoint( public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command, BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ => CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
@@ -447,7 +507,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public StoreResult<IntroductionEndpoints> ConsumeIntroduction( public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
MediationHandle handle, MediationHandle handle,
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ => CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
{ {
if (!_available) if (!_available)
{ {
@@ -471,12 +531,57 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
attempt.IntroductionConsumed = true; 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( return new(StoreResultCode.Success, new(
attempt.Command.AttemptId, Snapshot(attempt),
attempt.HostEndpoint, attempt.HostEndpoint,
attempt.ClientEndpoint)); attempt.ClientEndpoint));
}, cancellationToken); }, cancellationToken);
public StoreResult<bool> ConsumeConnectionTicket(
ConsumeConnectionTicketCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(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<bool> ConsumeReplay( public StoreResult<bool> ConsumeReplay(
ReplayConsumption consumption, ReplayConsumption consumption,
CancellationToken cancellationToken = default) => Atomic<bool>(now => CancellationToken cancellationToken = default) => Atomic<bool>(now =>
@@ -714,10 +819,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
ListingId = entry.Command.ListingId, ListingId = entry.Command.ListingId,
ClientSubject = entry.Command.ClientSubject, ClientSubject = entry.Command.ClientSubject,
ProtocolVersion = entry.Command.ProtocolVersion, 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, ExpiresAt = entry.WallExpiresAt,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
HostEndpoint = entry.HostEndpoint, HostEndpoint = entry.HostEndpoint,
ClientEndpoint = entry.ClientEndpoint, ClientEndpoint = entry.ClientEndpoint,
IntroductionConsumed = entry.IntroductionConsumed, IntroductionConsumed = entry.IntroductionConsumed,
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
}; };
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now) private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
@@ -789,6 +902,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.ProtocolVersion == 0 || command.ProtocolVersion == 0
|| !command.HostCapabilityFingerprint.IsValid || !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0) || command.ScopeAttemptLimit <= 0)
{ {
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command)); 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 CreateJoinAttemptCommand Command { get; } = command;
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint; public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint; public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
public TimeSpan Deadline { get; } = deadline; public TimeSpan Deadline { get; } = deadline;
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt; public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
public TimeSpan? TicketDeadline { get; set; }
public DateTimeOffset? TicketWallExpiresAt { get; set; }
public AttemptEndpointBinding? HostEndpoint { get; set; } public AttemptEndpointBinding? HostEndpoint { get; set; }
public AttemptEndpointBinding? ClientEndpoint { get; set; } public AttemptEndpointBinding? ClientEndpoint { get; set; }
public bool IntroductionConsumed { get; set; } public bool IntroductionConsumed { get; set; }
public bool ConnectionTicketConsumed { get; set; }
} }
private sealed record IdempotencyEntry( private sealed record IdempotencyEntry(
@@ -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,
};
}
@@ -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<ConnectionTicketConsumptionResult> left = Task.Run(() =>
{
start.Wait();
return validator.Consume(attempt, ticket);
});
Task<ConnectionTicketConsumptionResult> 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<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
Assert.Throws<ObjectDisposedException>(() => 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;
}
}
@@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests
"/health/live", "/health/live",
"/health/ready", "/health/ready",
"/v1/join-attempts", "/v1/join-attempts",
"/v1/join-attempts/{attemptId}",
"/v1/join-attempts/{attemptId}/outcome", "/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions", "/v1/sessions",
"/v1/sessions/{listingId}", "/v1/sessions/{listingId}",
@@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests
.GetProperty("security"); .GetProperty("security");
Assert.True(security[0].TryGetProperty("PublisherBearer", out _)); 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());
} }
} }
@@ -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<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(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<BrowseHostJoinAttemptsResponse>(
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(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<ApiError>(
await missingCapability.Content.ReadFromJsonAsync<ApiError>(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<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsAssignableFrom<T>(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<JoinHttpTestHost> 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<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.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();
}
}
}
@@ -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<CreateJoinAttemptResponse> first = fixture.Service.Create(
fixture.ClientSubject,
request);
JoinAttemptServiceResult<CreateJoinAttemptResponse> 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<CreateJoinAttemptResponse> 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<BrowseHostJoinAttemptsResponse> 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<BrowseHostJoinAttemptsResponse> 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<ConnectionTicketGrant> 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<StoreResult<bool>> left = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
Task<StoreResult<bool>> right = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
start.Set();
StoreResult<bool>[] 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<ConnectionTicketGrant>(
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);
}
}
@@ -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<CreateJoinAttemptResponse> result = Service.Create(
ClientSubject,
Request(listingId, idempotencyKey));
Assert.True(result.Succeeded);
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
}
public StoredJoinAttempt GetAttempt(
RegisterSessionResponse registration,
JoinAttemptId attemptId)
{
Assert.True(Sessions.Capabilities.TryFingerprint(
registration.LeaseToken,
out SecretFingerprint leaseFingerprint));
IReadOnlyList<StoredJoinAttempt> 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<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
attempt.MediationHandle);
Assert.True(introduced.Succeeded);
return introduced.Value!;
}
public void Dispose()
{
Cursors.Dispose();
Sessions.Dispose();
}
}
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests 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] [Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals() public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{ {
@@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests
{ {
LeaseLifetime = TimeSpan.FromSeconds(5), LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5), JoinAttemptLifetime = TimeSpan.FromSeconds(5),
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6), IdempotencyLifetime = TimeSpan.FromSeconds(6),
}; };
using SessionLeaseFixture fixture = new(options); using SessionLeaseFixture fixture = new(options);
@@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture
ProtocolVersion = listing.Definition.ProtocolVersion, ProtocolVersion = listing.Definition.ProtocolVersion,
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"), HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"), ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
}; };
} }
@@ -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<StoreResultCode, RendezvousErrorCode> 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<StoreResultCode>().Length, expected.Count);
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
{
Assert.Equal(item.Value, item.Key.ToContractError());
}
}
}
@@ -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 TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient