Compare commits

..

4 Commits

Author SHA1 Message Date
KyuubiYoru a9a2b3db35 feat: add bounded compatible session browser (#8)
quality-gate / quality (push) Successful in 57s
Closes #8
2026-07-16 06:06:29 +02:00
KyuubiYoru 49564c7e7e feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
Closes #7
2026-07-16 05:58:47 +02:00
KyuubiYoru 02ca502a76 feat: add atomic ephemeral state (#6)
quality-gate / quality (push) Successful in 57s
Closes #6
2026-07-16 05:32:48 +02:00
KyuubiYoru 47382ddadc feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s
Closes #5
2026-07-16 05:13:35 +02:00
49 changed files with 6883 additions and 44 deletions
+6
View File
@@ -81,6 +81,8 @@ The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md). threat model are indexed in [the architecture documentation](docs/architecture/README.md).
The frozen v1 wire surface is documented in the The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
## Development ## Development
@@ -97,5 +99,9 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
Run the bootstrap server with Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds `dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation. the configured UDP mediator port; both stop through normal host cancellation.
The launch profile uses an ephemeral development-only signing key. Production
startup fails closed until externally supplied game policies and `env:` signing
key references resolve to valid key material; no reusable game secret is stored
in this repository or the public Client package.
The project dependency rules and supported runtime choices are documented in The project dependency rules and supported runtime choices are documented in
[project and dependency boundaries](docs/architecture/project-boundaries.md). [project and dependency boundaries](docs/architecture/project-boundaries.md).
+322 -18
View File
@@ -75,8 +75,68 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"410": {
"description": "Gone",
"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": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -85,7 +145,12 @@
} }
} }
} }
} },
"security": [
{
"PublisherBearer": [ ]
}
]
}, },
"get": { "get": {
"tags": [ "tags": [
@@ -142,6 +207,13 @@
"format": "int32" "format": "int32"
} }
}, },
{
"name": "excludeFull",
"in": "query",
"schema": {
"type": "boolean"
}
},
{ {
"name": "cursor", "name": "cursor",
"in": "query", "in": "query",
@@ -161,8 +233,18 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -211,8 +293,68 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "Forbidden",
"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"
}
}
}
},
"410": {
"description": "Gone",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -221,7 +363,12 @@
} }
} }
} }
} },
"security": [
{
"PublisherBearer": [ ]
}
]
} }
}, },
"/v1/sessions/{listingId}": { "/v1/sessions/{listingId}": {
@@ -254,8 +401,48 @@
"204": { "204": {
"description": "No Content" "description": "No Content"
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "Forbidden",
"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": {
@@ -264,7 +451,12 @@
} }
} }
} }
} },
"security": [
{
"PublisherBearer": [ ]
}
]
}, },
"delete": { "delete": {
"tags": [ "tags": [
@@ -295,8 +487,38 @@
"204": { "204": {
"description": "No Content" "description": "No Content"
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -305,7 +527,12 @@
} }
} }
} }
} },
"security": [
{
"PublisherBearer": [ ]
}
]
}, },
"get": { "get": {
"tags": [ "tags": [
@@ -320,6 +547,40 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "contractVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "gameId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "environmentId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "protocolVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "uint32"
}
} }
], ],
"responses": { "responses": {
@@ -333,8 +594,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": {
@@ -875,7 +1156,9 @@
"leaseToken", "leaseToken",
"hostPresenceHandle", "hostPresenceHandle",
"hostPresenceCapability", "hostPresenceCapability",
"expiresAt" "expiresAt",
"leaseRenewAfterSeconds",
"hostPresenceRefreshAfterSeconds"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -901,6 +1184,14 @@
"expiresAt": { "expiresAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"leaseRenewAfterSeconds": {
"type": "integer",
"format": "int32"
},
"hostPresenceRefreshAfterSeconds": {
"type": "integer",
"format": "int32"
} }
} }
}, },
@@ -942,7 +1233,8 @@
"RenewLeaseResponse": { "RenewLeaseResponse": {
"required": [ "required": [
"contractVersion", "contractVersion",
"expiresAt" "expiresAt",
"renewAfterSeconds"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -953,6 +1245,10 @@
"expiresAt": { "expiresAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"renewAfterSeconds": {
"type": "integer",
"format": "int32"
} }
} }
}, },
@@ -1115,6 +1411,14 @@
} }
} }
} }
},
"securitySchemes": {
"PublisherBearer": {
"type": "http",
"description": "Tenant-scoped publisher credential issued during game provisioning.",
"scheme": "bearer",
"bearerFormat": "rv1 publisher credential"
}
} }
}, },
"tags": [ "tags": [
@@ -0,0 +1,101 @@
# ADR 0004: atomic ephemeral state and single-active availability
- Status: Accepted
- Date: 2026-07-16
- Tracking: #6
## Context
Listings, leases, endpoint observations, join attempts, and replay decisions must
move together. A partially committed authorization can expose an expired listing,
reuse a capability, or introduce an endpoint that was never authorized. V1 is a
single-active service, so it needs honest bounded in-memory behavior rather than
a database-shaped abstraction that implies unavailable durability or scale.
## Decision
`IEphemeralRendezvousStore` is the atomic boundary for directory, lease, presence,
attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation
serializes each transition under one process-local lock. This deliberately favors
simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling.
It retains only immutable listing data, opaque credential fingerprints, observed
endpoints, monotonic deadlines, and bounded idempotency/replay records.
Every collection has an independent configured ceiling. An operation checks all
of the capacity it needs before changing any collection. Exhaustion returns
`CapacityExceeded`; it does not evict live state, partially insert an operation,
or grow a fallback queue. Policy-provided per-owner listing and per-tenant active
attempt quotas are evaluated inside the same creation transition, so concurrent
requests cannot pass a check performed outside the store. New join authorization returns `ServiceUnavailable`
when the atomic store is unavailable and `Draining` once drain starts.
### Time and cleanup
Expiry uses an injected monotonic clock. Wall time is used only to return an
informational `ExpiresAt` value. Moving the wall clock forward or backward cannot
expire or prolong authority. Cleanup runs deterministically at the start of every
store operation and removes presence, attempts, listings, replay entries,
idempotency records, and revocations at their deadline. Removal of a listing also
removes its presence handle and every linked attempt before another caller can
observe the store.
### Concurrency and idempotency
- Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency
key plus a canonical request fingerprint. An exact duplicate returns the
original live result; reuse with different input returns `Conflict`; replay
after the resource has expired returns `Expired` until the bounded idempotency
record itself expires. Configuration requires idempotency retention to cover
every listing and attempt lifetime, preventing a live duplicate after eviction.
- Lease renewal is compare-and-swap by version. A stale renewal returns the latest
version as `Conflict`. Renew/delete races are serialized: renewal either commits
before deletion or observes the listing as absent.
- Host presence refresh is an atomic whole-endpoint replacement because NAT
mappings can legitimately change. Attempt capabilities are different: the
first endpoint bound for each role wins, an identical datagram is idempotent,
and a different replay is rejected. Introduction is consumed once atomically.
- Cancellation is checked before waiting for the lock and again after acquiring
it. A cancellation observed at either point makes no change. Once a synchronous
transition starts, it completes atomically and does not expose partial state.
### Visibility and revocation
A listing is visible or joinable only when its lease and authenticated UDP host
presence are both fresh. Public browsing is tenant/protocol scoped, excludes
unlisted sessions, and uses a stable listing-ID order with the contract page
ceiling. Revoking a listing or principal removes every listing, presence, and
attempt path in the same transition. A revocation is inserted before removal;
if the bounded revocation pool is full, the operation rejects without deleting
anything.
### Restart and graceful drain
A process restart creates a new store instance ID and starts empty. Old listing,
lease, attempt, endpoint, idempotency, and consumption state is not recovered.
Publishers must re-register; old callers receive typed `NotFound`, `Expired`, or
`ServiceUnavailable` outcomes rather than an ambiguous success. No database is
required or supported for the single-active MVP.
Drain is idempotent. It immediately rejects new registrations, attempts, and
lease extensions, while already-created attempts may bind endpoints and consume
their introduction during the configured window (at most 30 seconds). At the
deadline all active state is cleared atomically. Readiness is false while draining
or unavailable, and application shutdown starts drain before teardown.
## Future shared-store mapping
The interface uses explicit typed outcomes, TTLs, compare-and-swap versions,
idempotency records, and all-or-nothing multi-record transitions. A future Redis
implementation therefore requires authenticated transport, tenant-prefixed keys,
server-side scripts or transactions for each transition, TTLs based on the store's
authoritative time, and deterministic mediator routing. It must preserve these
semantics and pass the same contract tests before issue #18 may enable more than
one active instance.
## Consequences
- V1 has deterministic failure and restart behavior without durable gameplay state.
- A single lock is a measured capacity constraint, not a claim of horizontal scale.
- Transport and HTTP modules cannot bypass the store for authorization decisions.
- Operational code must treat `CapacityExceeded`, `Draining`, and
`ServiceUnavailable` as normal typed overload/availability outcomes.
@@ -0,0 +1,91 @@
# ADR 0005: authenticated session lease and presence lifecycle
- Status: Accepted
- Date: 2026-07-16
- Tracking: #7
## Context
A host needs to publish a player-facing session without letting an HTTP request
claim a public endpoint or remain visible after the gameplay socket disappears.
Registration retries must be safe, credentials must remain opaque, and policy or
ownership checks cannot race state mutation.
## Decision
The four host HTTP operations require `Authorization: Bearer <publisher credential>`.
The signed principal supplies the authoritative game, environment, publisher trust
mode, subject, and allowed regions. Request fields never widen that scope. Creation
and update apply the enabled `GamePolicy` to exact protocol, region, visibility,
bounded display/build/capacity values, and the allowlisted metadata schema.
Capacity reported by a host is advisory directory information. Rendezvous bounds
and publishes it but never treats it as final admission authority; the game host
still decides identity, bans, reserved slots, and whether a connection may join.
```mermaid
stateDiagram-v2
[*] --> AwaitingPresence: authorized register
AwaitingPresence --> Listed: valid host UDP presence
Listed --> AwaitingPresence: presence deadline passes
AwaitingPresence --> AwaitingPresence: lease renew or data update
Listed --> Listed: lease renew, data update, or presence refresh
AwaitingPresence --> Removed: lease expiry or delete
Listed --> Removed: lease expiry or delete
Removed --> [*]
```
Registration returns a listing ID, lease ID/token, host-presence handle/capability,
lease expiry, a 30-second renewal suggestion, and a 10-second presence-refresh
suggestion. The authoritative ceilings remain 60 seconds for the lease and 20
seconds for presence. Timing suggestions are server-controlled, not client-selected.
The lease token and presence capability are 256-bit opaque values derived with
HMAC-SHA256 from an in-memory per-process secret, a purpose label, the publisher
subject, the idempotency key, a canonical request fingerprint, and a random
per-registration derivation salt. Opaque IDs use separate purpose labels. Exact
retries read the retained non-secret salt and therefore reproduce the original
response without retaining plaintext credentials. Once the bounded idempotency
record expires, a new salt rotates IDs and capabilities so an old token cannot
regain authority. Metadata order is canonicalized before fingerprinting. The store
retains the salt and only a second keyed fingerprint of each token. Restart rotates
the derivation secret while the matching ephemeral state disappears.
Renew, update, and delete require both the same publisher subject and the lease
capability. Cross-owner or wrong-capability access returns the same not-found shape.
Update may change display name, build label, advisory capacity, and metadata only;
game, environment, region, protocol, visibility, trust mode, and opaque IDs remain
canonical. Delete is idempotent and does not reveal whether another publisher owns
the supplied ID.
### UDP presence
Only a structurally valid `HostPresence` datagram with the issued capability can
refresh presence. The public endpoint is the UDP packet's observed source on the
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
receive no response. Presence expiry demotes public visibility but keeps the lease,
so the same handle can restore visibility without changing session identity.
Public listing responses contain bounded listing data only. They never contain
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
keys, or canonical player identity.
## Failure semantics
- malformed or policy-invalid fields return a stable typed `InvalidRequest`;
- an unsupported gameplay protocol returns `IncompatibleProtocol`;
- missing/invalid publisher authentication returns `AuthenticationRequired`;
- cross-scope authorization returns `Forbidden` without resource disclosure;
- wrong owner/capability or expired state returns the tenant-hidden `NotFound`;
- idempotency reuse with changed input returns `Conflict`;
- publisher/global exhaustion returns `CapacityExceeded`; and
- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join.
## Consequences
- HTTP registration alone can never make a public session browseable.
- Plaintext session capabilities are returned to the intended host but are not
retained, logged, included in public listing DTOs, or exported as metrics.
- Re-registration after restart is the recovery path; there is no durable session
identity or gameplay state in Rendezvous.
@@ -0,0 +1,51 @@
# ADR 0006: bounded compatible session browser
- Status: Accepted
- Date: 2026-07-16
- Tracking: #8
## Decision
The public list endpoint requires game, environment, and exact gameplay protocol.
Region is optional, page size is 1100, and callers may exclude sessions whose
advisory current-player count has reached the advertised maximum. Lists contain
public sessions only and only while both lease and authenticated host presence are
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
their 128-bit unguessable listing ID only when the caller also supplies the exact
game, environment, and protocol scope.
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
the last ID plus every compatibility/filter field, a five-minute expiry, and an
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
rotates the key, matching the loss of ephemeral listings.
Pagination is a bounded live view, not a database snapshot. A record that remains
eligible and whose ID is greater than the cursor is returned exactly once. Records
removed or made stale disappear immediately. A record created after a page whose ID
sorts before that page's cursor is outside that traversal; callers refresh from the
first page to discover new sessions. This avoids skips or duplicates among stable
eligible records without retaining per-browser snapshot state.
The store reads at most page size plus one record. The service serializes against
the 256 KiB response ceiling and shortens a page before returning it when metadata
makes the requested count too large. A continuation cursor is emitted whenever an
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
and collection sizes are bounded before untrusted allocation can grow without a
ceiling.
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
region, visibility/trust presentation, advisory capacity, build/display labels, and
policy-validated string metadata. They contain no observed endpoint, lease,
capability, ticket, credential fingerprint, derivation salt, principal subject, or
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
still render values as text and must never execute markup, interpret endpoints, or
use metadata for authorization.
## Consequences
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
and optionally full sessions are removed before response construction.
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
ID; human join codes remain future work and require their own bounded abuse model.
- Host capacity remains advisory. The host makes the final admission decision.
+4
View File
@@ -6,9 +6,13 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md) - [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md)
- [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md) - [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md)
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md) - [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.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)
- [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)
- [Game provisioning and signing-key lifecycle](../security/provisioning.md)
These decisions intentionally leave gameplay authority, player identity, These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay simulation, persistence, social features, skill matchmaking, and gameplay
+84
View File
@@ -0,0 +1,84 @@
# Game provisioning and signing-key lifecycle
Tracking: #5
Rendezvous treats game and environment scope as provisioned policy, not caller
input. Production starts only when it can build an enabled policy registry and
load at least one currently active signing key from an external secret provider.
Unknown and disabled scopes fail closed.
## Policy boundary
Each `GamePolicy` fixes the allowed:
- game/environment pair and regions;
- exact gameplay protocol versions;
- publisher trust and listing visibility modes;
- metadata keys, required keys, per-value limits, total bytes, and key count;
- listing, anonymous-host, and active-attempt quotas; and
- dedicated fallback feature policy.
Publisher authorization first authenticates a typed principal, then derives the
authoritative game/environment from that principal. Request fields are compared
for mismatch detection but never replace the authenticated scope. Dedicated
workloads, short-lived player-host grants, anonymous unlisted publishers, and
operators are distinct principal types. Operator credentials cannot be used as
publisher credentials, and anonymous publishers cannot escalate to public
visibility.
## Signed credentials
Signed principal credentials use the compact form
`rv1.<key-id>.<base64url-payload>.<base64url-HMAC-SHA256>`. The signed payload
contains version, issuer, audience, subject, principal kind, bounded scope,
issued/not-before/expiry times, and a random nonce. It contains no signing key,
reusable publisher secret, player identity, or gameplay state.
Validation is deliberately ordered and bounded:
1. enforce the v1 opaque-credential length and four-segment grammar;
2. resolve a known, non-revoked key in its verification window;
3. compare the HMAC in fixed time;
4. parse canonical bounded JSON;
5. require exact version, issuer, and audience;
6. enforce clock skew, expiry, key lifetime, principal kind, and scope shape.
Failures return typed internal reasons without echoing the credential. Logs and
metrics must record only allowlisted tenant/principal/result dimensions; token,
key, secret-reference value, and raw key material are excluded.
## Rotation and revocation
A key is bound either to operator credentials only or to allowed publisher
credential kinds for exactly one game/environment. The verifier checks this
authority after the signature, so even a compromised game grant issuer cannot
mint a valid cross-game or operator credential.
A key also has three times: `NotBefore`, `SignUntil`, and `VerifyUntil`. Issuance
picks the newest authorized non-revoked key inside its signing window. Older credentials continue
to verify until the old key's verification window ends, providing an explicit
overlap. After `VerifyUntil` they fail as retired. Configuration revocation and
runtime revocation both reject immediately. A configured revoked key retains
only its public key ID/lifecycle metadata and does not require retired secret
material to remain available.
Key IDs are non-secret base64url identifiers. Secret references are resolved
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
the interface is replaceable by a deployment-specific vault/KMS adapter. The
committed development profile uses an in-memory random key identified by a
`development:ephemeral/...` reference. It never writes key material to disk and
all credentials become invalid when the process exits.
## Production configuration
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
descriptors, and game policies. A production key reference such as
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
belongs in `appsettings`, source control, examples, the Client package, URLs,
responses, logs, metrics, exceptions, or diagnostic dumps.
Readiness becomes true only after provisioning and UDP startup both succeed.
OpenAPI generation uses a pinned build-only host and does not start listeners or
bypass provisioning in a deployed server process.
@@ -46,10 +46,12 @@ public static class ContractValidation
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters); value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsBuildVersionValid(string? value) => public static bool IsBuildVersionValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); !string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
public static bool IsDisplayNameValid(string? value) => public static bool IsDisplayNameValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes); !string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
public static bool IsOpaqueHttpCredentialValid(string? value) => public static bool IsOpaqueHttpCredentialValid(string? value) =>
value is not null value is not null
@@ -99,6 +99,12 @@ public sealed class RegisterSessionResponse
[JsonRequired] [JsonRequired]
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
[JsonRequired]
public int LeaseRenewAfterSeconds { get; set; }
[JsonRequired]
public int HostPresenceRefreshAfterSeconds { get; set; }
} }
public sealed class RenewLeaseRequest public sealed class RenewLeaseRequest
@@ -117,6 +123,9 @@ public sealed class RenewLeaseResponse
[JsonRequired] [JsonRequired]
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
[JsonRequired]
public int RenewAfterSeconds { get; set; }
} }
public sealed class UpdateSessionRequest public sealed class UpdateSessionRequest
@@ -165,6 +174,8 @@ public sealed class BrowseSessionsRequest
public RegionId? RegionId { get; set; } public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems; public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public bool ExcludeFull { get; set; }
public string? Cursor { get; set; } public string? Cursor { get; set; }
} }
@@ -0,0 +1,181 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable
{
private const string Prefix = "rvc1";
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BrowserCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
EnvironmentId = query.Scope.EnvironmentId.Value,
ProtocolVersion = query.ProtocolVersion,
RegionId = query.RegionId?.Value,
ExcludeFull = query.ExcludeFull,
AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string content = $"{Prefix}.{encoded}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
try
{
string cursor = $"{content}.{EncodeBytes(signature)}";
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
}
finally
{
CryptographicOperations.ZeroMemory(signature);
}
}
public bool TryDecode(
string? cursor,
TenantScope scope,
uint protocolVersion,
RegionId? regionId,
bool excludeFull,
DateTimeOffset now,
out SessionListingId? after)
{
after = null;
if (cursor is null)
{
return true;
}
if (_disposed || !ContractValidation.IsCursorValid(cursor))
{
return false;
}
string[] segments = cursor.Split('.');
if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal))
{
return false;
}
byte[] expected = HMACSHA256.HashData(
_key,
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
if (!TryDecodeBytes(segments[2], out byte[] supplied))
{
CryptographicOperations.ZeroMemory(expected);
return false;
}
bool validSignature = supplied.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
CryptographicOperations.ZeroMemory(supplied);
CryptographicOperations.ZeroMemory(expected);
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
{
return false;
}
BrowserCursorPayload? payload;
try
{
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|| payload.ProtocolVersion != protocolVersion
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|| payload.ExcludeFull != excludeFull
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
{
return false;
}
after = listingId;
return true;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
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
{
[JsonRequired]
public string GameId { get; set; } = string.Empty;
[JsonRequired]
public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired]
public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; }
[JsonRequired]
public bool ExcludeFull { get; set; }
[JsonRequired]
public string AfterListingId { get; set; } = string.Empty;
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}
@@ -0,0 +1,157 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors,
IWallClock clock)
{
public BrowserServiceResult<BrowseSessionsResponse> Browse(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = Validate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
TenantScope scope = new(request.GameId, request.EnvironmentId);
if (!cursors.TryDecode(
request.Cursor,
scope,
request.ProtocolVersion,
request.RegionId,
request.ExcludeFull,
clock.UtcNow,
out SessionListingId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
VisibleListingQuery query = new(
scope,
request.ProtocolVersion,
request.RegionId,
request.PageSize + 1,
after,
request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
query,
cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.InternalError);
}
List<SessionListing> items = found.Value
.Take(request.PageSize)
.Select(ToContract)
.ToList();
bool hasMore = found.Value.Count > request.PageSize;
while (items.Count > 0)
{
string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null;
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes)
{
return new(RendezvousErrorCode.None, response);
}
items.RemoveAt(items.Count - 1);
hasMore = true;
}
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
}
public BrowserServiceResult<GetSessionResponse> Get(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default)
{
if (listingId.Value == Guid.Empty
|| string.IsNullOrEmpty(gameId.Value)
|| string.IsNullOrEmpty(environmentId.Value)
|| protocolVersion == 0)
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.NotFound);
}
StoredListing listing = found.Value;
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|| listing.Definition.ProtocolVersion != protocolVersion)
{
return new(RendezvousErrorCode.NotFound);
}
return new(RendezvousErrorCode.None, new GetSessionResponse
{
Session = ToContract(listing),
});
}
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ProtocolVersion == 0
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|| !ContractValidation.IsCursorValid(request.Cursor)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static SessionListing ToContract(StoredListing stored) => new()
{
ListingId = stored.Definition.ListingId,
GameId = stored.Definition.Scope.GameId,
EnvironmentId = stored.Definition.Scope.EnvironmentId,
RegionId = stored.Definition.RegionId,
ProtocolVersion = stored.Definition.ProtocolVersion,
BuildVersion = stored.Definition.BuildVersion,
DisplayName = stored.Definition.DisplayName,
Visibility = stored.Definition.Visibility,
PublisherTrustMode = stored.Definition.TrustMode,
Capacity = new()
{
CurrentPlayers = stored.Definition.CurrentPlayers,
MaximumPlayers = stored.Definition.MaximumPlayers,
},
Metadata = stored.Definition.Metadata.ToDictionary(
static item => item.Key,
static item => item.Value,
StringComparer.Ordinal),
};
}
@@ -1,4 +1,8 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace FinalFactory.Rendezvous.Server.Http; namespace FinalFactory.Rendezvous.Server.Http;
@@ -14,30 +18,52 @@ internal static class ContractEndpoints
sessions.MapPost("/", RegisterSession) sessions.MapPost("/", RegisterSession)
.Accepts<RegisterSessionRequest>("application/json") .Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created) .Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RegisterSession"); .WithName("RegisterSession");
sessions.MapPost("/{listingId}/renew", RenewLease) sessions.MapPost("/{listingId}/renew", RenewLease)
.Accepts<RenewLeaseRequest>("application/json") .Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>() .Produces<RenewLeaseResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RenewSessionLease"); .WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession) sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json") .Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("UpdateSession"); .WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession) sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json") .Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("DeleteSession"); .WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions) sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>() .Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession"); .WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>() .Produces<BrowseHostJoinAttemptsResponse>()
@@ -61,20 +87,115 @@ internal static class ContractEndpoints
return endpoints; return endpoints;
} }
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) => private static IResult RegisterSession(
NotImplemented(); [FromBody] RegisterSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error);
}
private static IResult RenewLease( private static IResult RenewLease(
SessionListingId listingId, SessionListingId listingId,
[FromBody] RenewLeaseRequest request) => NotImplemented(); [FromBody] RenewLeaseRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult UpdateSession( private static IResult UpdateSession(
SessionListingId listingId, SessionListingId listingId,
[FromBody] UpdateSessionRequest request) => NotImplemented(); [FromBody] UpdateSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Update(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult DeleteSession( private static IResult DeleteSession(
SessionListingId listingId, SessionListingId listingId,
[FromBody] DeleteSessionRequest request) => NotImplemented(); [FromBody] DeleteSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Delete(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult BrowseSessions( private static IResult BrowseSessions(
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
@@ -83,9 +204,64 @@ internal static class ContractEndpoints
[FromQuery] uint protocolVersion, [FromQuery] uint protocolVersion,
[FromQuery] string? regionId, [FromQuery] string? regionId,
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented(); [FromQuery] bool? excludeFull,
[FromQuery] string? cursor,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
private static IResult GetSession(SessionListingId listingId) => NotImplemented(); BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult GetSession(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
{
return Error(RendezvousErrorCode.UnsupportedContractVersion);
}
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult BrowseHostJoinAttempts( private static IResult BrowseHostJoinAttempts(
SessionListingId listingId, SessionListingId listingId,
@@ -109,4 +285,72 @@ internal static class ContractEndpoints
}, },
ContractJson.Options, ContractJson.Options,
statusCode: NotImplementedStatus); statusCode: NotImplementedStatus);
private static bool TryAuthenticatePublisher(
string? authorizationHeader,
PrincipalCredentialService credentials,
IWallClock clock,
out AuthenticatedPrincipal? principal)
{
principal = null;
const string bearerPrefix = "Bearer ";
if (authorizationHeader is null
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string token = authorizationHeader[bearerPrefix.Length..];
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
{
return false;
}
principal = validation.Principal;
return true;
}
private static IResult Error(RendezvousErrorCode code) => Results.Json(
new ApiError
{
Code = code,
Message = ErrorMessage(code),
},
ContractJson.Options,
statusCode: ErrorStatus(code));
private static IResult AuthenticationRequired(HttpContext context)
{
context.Response.Headers.WWWAuthenticate = "Bearer";
return Error(RendezvousErrorCode.AuthenticationRequired);
}
private static int ErrorStatus(RendezvousErrorCode code) => code switch
{
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status400BadRequest,
};
private static string ErrorMessage(RendezvousErrorCode code) => code switch
{
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
_ => "The session request is invalid.",
};
} }
@@ -0,0 +1,37 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Http;
internal sealed class RendezvousExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (httpContext.Response.HasStarted)
{
return false;
}
bool invalidRequest = exception is BadHttpRequestException or JsonException;
httpContext.Response.StatusCode = invalidRequest
? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(
new ApiError
{
Code = invalidRequest
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.InternalError,
Message = invalidRequest
? "The request body, route, or query value is invalid."
: "The service could not complete the request.",
},
ContractJson.Options,
cancellationToken).ConfigureAwait(false);
return true;
}
}
+107 -7
View File
@@ -1,17 +1,21 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.OpenApi; using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
bool isOpenApiGeneration = Environment.GetCommandLineArgs().Any(static argument => bool isOpenApiGeneration = string.Equals(
string.Equals( System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
Path.GetFileName(argument), "GetDocument.Insider",
"dotnet-getdocument.dll", StringComparison.Ordinal);
StringComparison.OrdinalIgnoreCase));
builder.Services.AddOpenApi("v1", static options => builder.Services.AddOpenApi("v1", static options =>
{
options.AddSchemaTransformer(static (schema, context, cancellationToken) => options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
{ {
Type type = context.JsonTypeInfo.Type; Type type = context.JsonTypeInfo.Type;
@@ -31,9 +35,96 @@ builder.Services.AddOpenApi("v1", static options =>
} }
return Task.CompletedTask; return Task.CompletedTask;
})); });
options.AddDocumentTransformer(static (document, context, cancellationToken) =>
{
const string schemeName = "PublisherBearer";
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??=
new Dictionary<string, IOpenApiSecurityScheme>(StringComparer.Ordinal);
document.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "rv1 publisher credential",
Description = "Tenant-scoped publisher credential issued during game provisioning.",
};
HashSet<string> securedOperations = new(StringComparer.Ordinal)
{
"RegisterSession",
"RenewSessionLease",
"UpdateSession",
"DeleteSession",
};
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
foreach (OpenApiPathItem path in document.Paths.Values)
{
if (path.Operations is null)
{
continue;
}
foreach (OpenApiOperation operation in path.Operations.Values.Where(
operation => securedOperations.Contains(operation.OperationId ?? string.Empty)))
{
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[reference] = [],
});
}
}
return Task.CompletedTask;
});
});
builder.Services.ConfigureHttpJsonOptions(static options => builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions)); ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
SystemRendezvousClock rendezvousClock = new();
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore stateStore = new(
stateOptions,
rendezvousClock,
rendezvousClock);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
if (isOpenApiGeneration)
{
builder.Services.AddSingleton(new ProvisioningReadiness(false));
}
else
{
ProvisioningOptions provisioningOptions = builder.Configuration
.GetSection(ProvisioningOptions.SectionName)
.Get<ProvisioningOptions>() ?? new ProvisioningOptions();
ISecretProvider secretProvider = builder.Environment.IsDevelopment()
? new EphemeralDevelopmentSecretProvider()
: new EnvironmentSecretProvider();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
provisioningOptions,
secretProvider,
DateTimeOffset.UtcNow);
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
EphemeralCapabilityIssuer sessionCapabilities = new();
builder.Services.AddSingleton(sessionCapabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true));
}
builder.Services builder.Services
.AddOptions<UdpMediatorOptions>() .AddOptions<UdpMediatorOptions>()
.BindConfiguration(UdpMediatorOptions.SectionName) .BindConfiguration(UdpMediatorOptions.SectionName)
@@ -50,7 +141,9 @@ if (!isOpenApiGeneration)
} }
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
app.UseExceptionHandler();
app.MapOpenApi(); app.MapOpenApi();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
app.MapGet( app.MapGet(
@@ -61,7 +154,14 @@ app.MapGet(
.WithTags("Health"); .WithTags("Health");
app.MapGet( app.MapGet(
"/health/ready", "/health/ready",
static (UdpMediatorService mediator) => mediator.LocalEndpoint is null static (
UdpMediatorService mediator,
ProvisioningReadiness provisioning,
IEphemeralRendezvousStore state) =>
mediator.LocalEndpoint is null
|| !provisioning.IsReady
|| !state.IsAvailable
|| state.IsDraining
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" })) : Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>() .Produces<HealthResponse>()
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"development": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://127.0.0.1:5096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,80 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicy
{
private readonly HashSet<uint> _protocolVersions;
private readonly HashSet<RegionId> _regions;
private readonly HashSet<ListingVisibility> _visibilityModes;
private readonly HashSet<PublisherTrustMode> _publisherTrustModes;
private readonly Dictionary<string, int> _metadataValueMaxBytes;
private readonly HashSet<string> _requiredMetadataKeys;
public GamePolicy(GamePolicyOptions options)
{
GameId = new GameId(options.GameId);
EnvironmentId = new EnvironmentId(options.EnvironmentId);
Enabled = options.Enabled;
_protocolVersions = new HashSet<uint>(options.ProtocolVersions);
_regions = options.Regions.Select(static region => new RegionId(region)).ToHashSet();
_visibilityModes = new HashSet<ListingVisibility>(options.VisibilityModes);
_publisherTrustModes = new HashSet<PublisherTrustMode>(options.PublisherTrustModes);
_metadataValueMaxBytes = new Dictionary<string, int>(
options.MetadataValueMaxBytes,
StringComparer.Ordinal);
_requiredMetadataKeys = new HashSet<string>(
options.RequiredMetadataKeys,
StringComparer.Ordinal);
MetadataMaxBytes = options.MetadataMaxBytes;
MetadataMaxKeys = options.MetadataMaxKeys;
MaxListingsPerPrincipal = options.MaxListingsPerPrincipal;
MaxAnonymousListingsPerAddress = options.MaxAnonymousListingsPerAddress;
MaxActiveJoinAttempts = options.MaxActiveJoinAttempts;
FallbackPolicy = options.FallbackPolicy;
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public bool Enabled { get; }
public int MetadataMaxBytes { get; }
public int MetadataMaxKeys { get; }
public int MaxListingsPerPrincipal { get; }
public int MaxAnonymousListingsPerAddress { get; }
public int MaxActiveJoinAttempts { get; }
public FallbackPolicyMode FallbackPolicy { get; }
public bool AllowsProtocol(uint protocolVersion) =>
_protocolVersions.Contains(protocolVersion);
public bool AllowsRegion(RegionId regionId) => _regions.Contains(regionId);
public bool AllowsVisibility(ListingVisibility visibility) =>
_visibilityModes.Contains(visibility);
public bool AllowsPublisherTrust(PublisherTrustMode trustMode) =>
_publisherTrustModes.Contains(trustMode);
public bool AllowsMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (!ContractValidation.IsMetadataValid(metadata)
|| metadata!.Count > MetadataMaxKeys
|| !_requiredMetadataKeys.IsSubsetOf(metadata.Keys))
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (!_metadataValueMaxBytes.TryGetValue(item.Key, out int maximumBytes)
|| !ContractValidation.IsUtf8LengthWithin(item.Value, maximumBytes))
{
return false;
}
}
return JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options).Length
<= MetadataMaxBytes;
}
}
@@ -0,0 +1,118 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicyRegistry
{
private readonly Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> _policies;
private GamePolicyRegistry(
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies) =>
_policies = policies;
public bool HasEnabledPolicies => _policies.Values.Any(static policy => policy.Enabled);
public IEnumerable<GamePolicy> EnabledPolicies =>
_policies.Values.Where(static policy => policy.Enabled);
public static GamePolicyRegistry Create(IEnumerable<GamePolicyOptions> options)
{
GamePolicyOptions[] configuredPolicies = options.ToArray();
if (configuredPolicies.Length > ProvisioningLimits.MaxGamePolicies)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxGamePolicies} game policies may be configured.");
}
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies = [];
foreach (GamePolicyOptions policyOptions in configuredPolicies)
{
Validate(policyOptions);
GamePolicy policy = new(policyOptions);
if (!policies.TryAdd((policy.GameId, policy.EnvironmentId), policy))
{
throw new ProvisioningConfigurationException(
$"Duplicate game/environment policy: {policy.GameId}/{policy.EnvironmentId}.");
}
}
return new GamePolicyRegistry(policies);
}
public bool TryGet(
GameId gameId,
EnvironmentId environmentId,
out GamePolicy? policy)
{
if (_policies.TryGetValue((gameId, environmentId), out GamePolicy? candidate)
&& candidate.Enabled)
{
policy = candidate;
return true;
}
policy = null;
return false;
}
private static void Validate(GamePolicyOptions options)
{
if (!GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
"Game policies require valid game and environment IDs.");
}
if (options.ProtocolVersions.Count is 0 or > ProvisioningLimits.MaxProtocolVersionsPerPolicy
|| options.ProtocolVersions.Contains(0)
|| options.ProtocolVersions.Count != options.ProtocolVersions.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique non-zero protocol versions.");
}
if (options.Regions.Count is 0 or > ProvisioningLimits.MaxRegionsPerPolicy
|| options.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| options.Regions.Count != options.Regions.Distinct(StringComparer.Ordinal).Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique valid regions.");
}
if (options.VisibilityModes.Count == 0
|| options.VisibilityModes.Any(static mode => !Enum.IsDefined(mode))
|| options.VisibilityModes.Count != options.VisibilityModes.Distinct().Count()
|| options.PublisherTrustModes.Count == 0
|| options.PublisherTrustModes.Any(static mode => !Enum.IsDefined(mode))
|| options.PublisherTrustModes.Count != options.PublisherTrustModes.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires valid visibility and trust modes.");
}
if (options.MetadataMaxBytes is < 2 or > ContractLimits.MetadataMaxBytes
|| options.MetadataMaxKeys is < 0 or > ContractLimits.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Count > options.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Any(static item =>
string.IsNullOrWhiteSpace(item.Key)
|| !ContractValidation.IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| item.Value is < 0 or > ContractLimits.MetadataValueMaxBytes)
|| options.RequiredMetadataKeys.Any(key =>
!options.MetadataValueMaxBytes.ContainsKey(key)))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has an invalid metadata schema.");
}
if (options.MaxListingsPerPrincipal is < 1 or > ProvisioningLimits.MaxListingsPerPrincipal
|| options.MaxAnonymousListingsPerAddress < 0
|| options.MaxAnonymousListingsPerAddress > options.MaxListingsPerPrincipal
|| options.MaxActiveJoinAttempts is < 1
or > ProvisioningLimits.MaxActiveJoinAttemptsPerPolicy
|| !Enum.IsDefined(options.FallbackPolicy))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has invalid quotas or fallback policy.");
}
}
}
@@ -0,0 +1,451 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PrincipalCredentialService
{
private const string TokenPrefix = "rv1";
private readonly string _issuer;
private readonly string _audience;
private readonly TimeSpan _clockSkew;
private readonly SigningKeyRing _keyRing;
public PrincipalCredentialService(
string issuer,
string audience,
TimeSpan clockSkew,
SigningKeyRing keyRing)
{
if (!IsSafeAuthority(issuer) || !IsSafeAuthority(audience))
{
throw new ProvisioningConfigurationException(
"Credential issuer and audience are required.");
}
if (clockSkew < TimeSpan.Zero || clockSkew > TimeSpan.FromSeconds(30))
{
throw new ProvisioningConfigurationException(
"Credential clock skew must be between zero and 30 seconds.");
}
_issuer = issuer;
_audience = audience;
_clockSkew = clockSkew;
_keyRing = keyRing;
}
public string Issue(AuthenticatedPrincipal principal, DateTimeOffset now)
{
if (!IsSafeSubject(principal.Subject))
{
throw new ArgumentException(
"Principal subjects must be 1128 visible ASCII characters.",
nameof(principal));
}
if (principal.ExpiresAt <= now)
{
throw new ArgumentException("Cannot issue an already-expired principal.", nameof(principal));
}
CredentialPayload payload = CreatePayload(principal, now);
if (CreatePrincipal(payload, principal.ExpiresAt) is null)
{
throw new ArgumentException(
"The principal contains an invalid kind or scope.",
nameof(principal));
}
if (!_keyRing.TryGetSigningKey(
now,
payload.Kind,
payload.GameId,
payload.EnvironmentId,
out SigningKey? signingKey)
|| signingKey is null)
{
throw new InvalidOperationException("No active signing key is available.");
}
if (principal.ExpiresAt > signingKey.VerifyUntil)
{
throw new InvalidOperationException(
"The active key verification window is shorter than the credential lifetime.");
}
string encodedPayload = Base64Url.Encode(
JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"{TokenPrefix}.{signingKey.KeyId}.{encodedPayload}";
string signature = Base64Url.Encode(signingKey.Sign(signedContent));
string token = $"{signedContent}.{signature}";
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
throw new InvalidOperationException("The signed credential exceeds the v1 size limit.");
}
return token;
}
public CredentialValidationResult Validate(string? token, DateTimeOffset now)
{
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string[] segments = token!.Split('.');
if (segments.Length != 4
|| !string.Equals(segments[0], TokenPrefix, StringComparison.Ordinal)
|| segments[1].Length == 0)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
VerificationKeyLookup lookup = _keyRing.FindVerificationKey(
segments[1],
now,
out SigningKey? signingKey);
if (lookup != VerificationKeyLookup.Available || signingKey is null)
{
return CredentialValidationResult.Invalid(lookup switch
{
VerificationKeyLookup.Revoked => CredentialValidationError.KeyRevoked,
VerificationKeyLookup.NotYetValid => CredentialValidationError.KeyNotYetValid,
VerificationKeyLookup.Retired => CredentialValidationError.KeyRetired,
_ => CredentialValidationError.UnknownKey,
});
}
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
byte[] expectedSignature = signingKey.Sign(signedContent);
bool signatureMatches = suppliedSignature.Length == expectedSignature.Length
&& CryptographicOperations.FixedTimeEquals(suppliedSignature, expectedSignature);
CryptographicOperations.ZeroMemory(suppliedSignature);
CryptographicOperations.ZeroMemory(expectedSignature);
if (!signatureMatches)
{
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
}
if (!Base64Url.TryDecode(segments[2], out byte[]? encodedPayload))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
CredentialPayload? payload;
try
{
payload = JsonSerializer.Deserialize<CredentialPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null || payload.Version != ContractLimits.ContractVersion)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (!string.Equals(payload.Issuer, _issuer, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.IssuerMismatch);
}
if (!string.Equals(payload.Audience, _audience, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.AudienceMismatch);
}
if (!signingKey.Authorizes(payload.Kind, payload.GameId, payload.EnvironmentId))
{
return CredentialValidationResult.Invalid(CredentialValidationError.KeyScopeMismatch);
}
DateTimeOffset issuedAt;
DateTimeOffset notBefore;
DateTimeOffset expiresAt;
try
{
issuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAtUnixSeconds);
notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBeforeUnixSeconds);
expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAtUnixSeconds);
}
catch (ArgumentOutOfRangeException)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (issuedAt > now + _clockSkew || notBefore > now + _clockSkew)
{
return CredentialValidationResult.Invalid(CredentialValidationError.NotYetValid);
}
if (expiresAt <= now - _clockSkew || expiresAt <= notBefore)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Expired);
}
if (issuedAt > notBefore
|| issuedAt < signingKey.NotBefore - _clockSkew
|| expiresAt > signingKey.VerifyUntil)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
AuthenticatedPrincipal? principal = CreatePrincipal(payload, expiresAt);
return principal is null
? CredentialValidationResult.Invalid(CredentialValidationError.ScopeInvalid)
: CredentialValidationResult.Valid(principal);
}
public override string ToString() => "[PrincipalCredentialService: key material and credentials redacted]";
private CredentialPayload CreatePayload(AuthenticatedPrincipal principal, DateTimeOffset now)
{
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = _issuer,
Audience = _audience,
Subject = principal.Subject,
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = principal.ExpiresAt.ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
switch (principal)
{
case DedicatedPublisherPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.DedicatedPublisher);
break;
case PlayerHostGrantPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.PlayerHostGrant);
break;
case OperatorPrincipal operatorPrincipal:
payload.Kind = PrincipalCredentialKind.Operator;
payload.Permissions = operatorPrincipal.Permissions.Order().ToList();
break;
default:
throw new ArgumentException(
"Anonymous principals cannot receive reusable signed credentials.",
nameof(principal));
}
return payload;
}
private static void SetPublisherPayload(
CredentialPayload payload,
IPublisherPrincipal publisher,
PrincipalCredentialKind kind)
{
payload.Kind = kind;
payload.GameId = publisher.GameId.ToString();
payload.EnvironmentId = publisher.EnvironmentId.ToString();
payload.Regions = publisher.AllowedRegions
.Select(static region => region.ToString())
.Order(StringComparer.Ordinal)
.ToList();
}
private static AuthenticatedPrincipal? CreatePrincipal(
CredentialPayload payload,
DateTimeOffset expiresAt)
{
if (!IsSafeSubject(payload.Subject)
|| !Guid.TryParseExact(payload.Nonce, "N", out Guid nonce)
|| nonce == Guid.Empty)
{
return null;
}
if (payload.Kind == PrincipalCredentialKind.Operator)
{
if (payload.GameId is not null
|| payload.EnvironmentId is not null
|| payload.Regions.Count != 0
|| payload.Permissions.Count == 0
|| payload.Permissions.Any(static permission => !Enum.IsDefined(permission))
|| payload.Permissions.Count != payload.Permissions.Distinct().Count())
{
return null;
}
return new OperatorPrincipal(
payload.Subject,
expiresAt,
new HashSet<OperatorPermission>(payload.Permissions));
}
if (!GameId.TryParse(payload.GameId, out GameId gameId)
|| !EnvironmentId.TryParse(payload.EnvironmentId, out EnvironmentId environmentId)
|| payload.Regions.Count == 0
|| payload.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| payload.Regions.Count != payload.Regions.Distinct(StringComparer.Ordinal).Count()
|| payload.Permissions.Count != 0)
{
return null;
}
HashSet<RegionId> regions = payload.Regions.Select(static region => new RegionId(region)).ToHashSet();
return payload.Kind switch
{
PrincipalCredentialKind.DedicatedPublisher => new DedicatedPublisherPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
PrincipalCredentialKind.PlayerHostGrant => new PlayerHostGrantPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
_ => null,
};
}
private static bool IsSafeSubject(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
private static bool IsSafeAuthority(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
}
internal sealed class CredentialPayload
{
[JsonRequired]
public int Version { get; set; }
[JsonRequired]
public string Issuer { get; set; } = string.Empty;
[JsonRequired]
public string Audience { get; set; } = string.Empty;
[JsonRequired]
public string Subject { get; set; } = string.Empty;
[JsonRequired]
public PrincipalCredentialKind Kind { get; set; }
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public List<string> Regions { get; set; } = [];
public List<OperatorPermission> Permissions { get; set; } = [];
[JsonRequired]
public long IssuedAtUnixSeconds { get; set; }
[JsonRequired]
public long NotBeforeUnixSeconds { get; set; }
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
[JsonRequired]
public string Nonce { get; set; } = string.Empty;
}
internal readonly record struct CredentialValidationResult(
bool IsValid,
CredentialValidationError Error,
AuthenticatedPrincipal? Principal)
{
public static CredentialValidationResult Valid(AuthenticatedPrincipal principal) =>
new(true, CredentialValidationError.None, principal);
public static CredentialValidationResult Invalid(CredentialValidationError error) =>
new(false, error, null);
public override string ToString() => $"[CredentialValidation: {Error}, credential redacted]";
}
internal enum CredentialValidationError
{
None = 0,
Malformed = 1,
UnknownKey = 2,
KeyRevoked = 3,
KeyNotYetValid = 4,
KeyRetired = 5,
SignatureInvalid = 6,
PayloadInvalid = 7,
IssuerMismatch = 8,
AudienceMismatch = 9,
KeyScopeMismatch = 10,
NotYetValid = 11,
Expired = 12,
ScopeInvalid = 13,
}
internal static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
public static bool TryDecode(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('_', '/');
int remainder = padded.Length % 4;
if (remainder == 1)
{
return false;
}
padded += remainder switch
{
0 => string.Empty,
2 => "==",
3 => "=",
_ => string.Empty,
};
try
{
bytes = Convert.FromBase64String(padded);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,107 @@
using System.Collections.Frozen;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal abstract record AuthenticatedPrincipal(
string Subject,
DateTimeOffset ExpiresAt);
internal interface IPublisherPrincipal
{
string Subject { get; }
DateTimeOffset ExpiresAt { get; }
GameId GameId { get; }
EnvironmentId EnvironmentId { get; }
PublisherTrustMode TrustMode { get; }
IReadOnlySet<RegionId> AllowedRegions { get; }
}
internal sealed record DedicatedPublisherPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public DedicatedPublisherPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.ManagedDedicated;
}
internal sealed record PlayerHostGrantPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public PlayerHostGrantPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.PlayerGrant;
}
internal sealed record AnonymousUnlistedPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public AnonymousUnlistedPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.AnonymousUnlisted;
}
internal sealed record OperatorPrincipal : AuthenticatedPrincipal
{
public OperatorPrincipal(
string subject,
DateTimeOffset expiresAt,
IEnumerable<OperatorPermission> permissions)
: base(subject, expiresAt) => Permissions = permissions.ToFrozenSet();
public IReadOnlySet<OperatorPermission> Permissions { get; }
}
internal enum OperatorPermission
{
ReadPolicy = 1,
ManagePolicy = 2,
RevokePublisher = 3,
RotateKeys = 4,
}
internal enum PrincipalCredentialKind
{
DedicatedPublisher = 1,
PlayerHostGrant = 2,
Operator = 3,
}
@@ -0,0 +1,71 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningOptions
{
public const string SectionName = "Rendezvous:Provisioning";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int ClockSkewSeconds { get; set; } = 30;
public List<SigningKeyOptions> SigningKeys { get; set; } = [];
public List<GamePolicyOptions> Games { get; set; } = [];
}
internal sealed class SigningKeyOptions
{
public string KeyId { get; set; } = string.Empty;
public string SecretReference { get; set; } = string.Empty;
public List<PrincipalCredentialKind> CredentialKinds { get; set; } = [];
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public DateTimeOffset NotBefore { get; set; }
public DateTimeOffset SignUntil { get; set; }
public DateTimeOffset VerifyUntil { get; set; }
public bool Revoked { get; set; }
}
internal sealed class GamePolicyOptions
{
public string GameId { get; set; } = string.Empty;
public string EnvironmentId { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public List<uint> ProtocolVersions { get; set; } = [];
public List<string> Regions { get; set; } = [];
public List<ListingVisibility> VisibilityModes { get; set; } = [];
public List<PublisherTrustMode> PublisherTrustModes { get; set; } = [];
public Dictionary<string, int> MetadataValueMaxBytes { get; set; } =
new(StringComparer.Ordinal);
public List<string> RequiredMetadataKeys { get; set; } = [];
public int MetadataMaxBytes { get; set; } = ContractLimits.MetadataMaxBytes;
public int MetadataMaxKeys { get; set; } = ContractLimits.MetadataMaxKeys;
public int MaxListingsPerPrincipal { get; set; } = 100;
public int MaxAnonymousListingsPerAddress { get; set; } = 2;
public int MaxActiveJoinAttempts { get; set; } = 1_000;
public FallbackPolicyMode FallbackPolicy { get; set; }
}
internal enum FallbackPolicyMode
{
Disabled = 0,
DedicatedEndpointAllowed = 1,
}
internal static class ProvisioningLimits
{
public const int MaxGamePolicies = 1_024;
public const int MaxSigningKeys = 128;
public const int MaxProtocolVersionsPerPolicy = 64;
public const int MaxRegionsPerPolicy = 32;
public const int MaxListingsPerPrincipal = 25_000;
public const int MaxActiveJoinAttemptsPerPolicy = 10_000;
}
internal sealed class ProvisioningConfigurationException : Exception
{
public ProvisioningConfigurationException(string message)
: base(message)
{
}
}
@@ -0,0 +1,120 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningRuntime : IDisposable
{
private readonly IDisposable? _secretProviderLifetime;
private ProvisioningRuntime(
GamePolicyRegistry policies,
SigningKeyRing signingKeys,
PrincipalCredentialService credentials,
PublisherAuthorizationService publisherAuthorization,
IDisposable? secretProviderLifetime)
{
Policies = policies;
SigningKeys = signingKeys;
Credentials = credentials;
PublisherAuthorization = publisherAuthorization;
_secretProviderLifetime = secretProviderLifetime;
}
public GamePolicyRegistry Policies { get; }
public SigningKeyRing SigningKeys { get; }
public PrincipalCredentialService Credentials { get; }
public PublisherAuthorizationService PublisherAuthorization { get; }
public static ProvisioningRuntime Create(
ProvisioningOptions options,
ISecretProvider secretProvider,
DateTimeOffset now)
{
try
{
SigningKeyRing signingKeys = SigningKeyRing.Create(options.SigningKeys, secretProvider);
try
{
if (!signingKeys.HasKeys
|| !signingKeys.HasActiveSigningKey(now))
{
throw new ProvisioningConfigurationException(
"At least one active signing key with available production key material is required.");
}
GamePolicyRegistry policies = GamePolicyRegistry.Create(options.Games);
if (!policies.HasEnabledPolicies)
{
throw new ProvisioningConfigurationException(
"At least one enabled game/environment policy is required.");
}
foreach (GamePolicy policy in policies.EnabledPolicies)
{
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.ManagedDedicated,
PrincipalCredentialKind.DedicatedPublisher,
now);
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.PlayerGrant,
PrincipalCredentialKind.PlayerHostGrant,
now);
}
PrincipalCredentialService credentials = new(
options.Issuer,
options.Audience,
TimeSpan.FromSeconds(options.ClockSkewSeconds),
signingKeys);
PublisherAuthorizationService authorization = new(policies);
return new ProvisioningRuntime(
policies,
signingKeys,
credentials,
authorization,
secretProvider as IDisposable);
}
catch
{
signingKeys.Dispose();
throw;
}
}
catch
{
(secretProvider as IDisposable)?.Dispose();
throw;
}
}
public void Dispose()
{
SigningKeys.Dispose();
_secretProviderLifetime?.Dispose();
}
private static void RequirePublisherKey(
SigningKeyRing signingKeys,
GamePolicy policy,
PublisherTrustMode trustMode,
PrincipalCredentialKind credentialKind,
DateTimeOffset now)
{
if (policy.AllowsPublisherTrust(trustMode)
&& !signingKeys.HasActiveSigningKey(
now,
credentialKind,
policy.GameId.ToString(),
policy.EnvironmentId.ToString()))
{
throw new ProvisioningConfigurationException(
$"Policy {policy.GameId}/{policy.EnvironmentId} has no active {credentialKind} key.");
}
}
}
internal sealed record ProvisioningReadiness(bool IsReady);
@@ -0,0 +1,119 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PublisherAuthorizationService(GamePolicyRegistry policies)
{
public PublisherAuthorizationResult Authorize(
AuthenticatedPrincipal principal,
GameId requestedGameId,
EnvironmentId requestedEnvironmentId,
RegionId requestedRegionId,
uint requestedProtocolVersion,
ListingVisibility requestedVisibility,
IReadOnlyDictionary<string, string> requestedMetadata,
DateTimeOffset now)
{
if (principal.ExpiresAt <= now)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PrincipalExpired);
}
if (principal is not IPublisherPrincipal publisher)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.NotPublisher);
}
if (publisher.GameId != requestedGameId
|| publisher.EnvironmentId != requestedEnvironmentId)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ScopeMismatch);
}
if (!policies.TryGet(publisher.GameId, publisher.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PolicyNotFound);
}
if (!policy.AllowsPublisherTrust(publisher.TrustMode))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.TrustModeNotAllowed);
}
if (!publisher.AllowedRegions.Contains(requestedRegionId)
|| !policy.AllowsRegion(requestedRegionId))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.RegionNotAllowed);
}
if (!policy.AllowsProtocol(requestedProtocolVersion))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ProtocolNotAllowed);
}
if (publisher.TrustMode == PublisherTrustMode.AnonymousUnlisted
&& requestedVisibility != ListingVisibility.Unlisted)
{
return PublisherAuthorizationResult.Denied(
PublisherAuthorizationError.AnonymousMustBeUnlisted);
}
if (!policy.AllowsVisibility(requestedVisibility))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.VisibilityNotAllowed);
}
if (!policy.AllowsMetadata(requestedMetadata))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.MetadataNotAllowed);
}
return PublisherAuthorizationResult.Allowed(new AuthorizedPublisherContext(
publisher.Subject,
publisher.GameId,
publisher.EnvironmentId,
requestedRegionId,
requestedProtocolVersion,
requestedVisibility,
publisher.TrustMode,
policy));
}
}
internal sealed record AuthorizedPublisherContext(
string Subject,
GameId GameId,
EnvironmentId EnvironmentId,
RegionId RegionId,
uint ProtocolVersion,
ListingVisibility Visibility,
PublisherTrustMode TrustMode,
GamePolicy Policy);
internal readonly record struct PublisherAuthorizationResult(
bool IsAllowed,
PublisherAuthorizationError Error,
AuthorizedPublisherContext? Context)
{
public static PublisherAuthorizationResult Allowed(AuthorizedPublisherContext context) =>
new(true, PublisherAuthorizationError.None, context);
public static PublisherAuthorizationResult Denied(PublisherAuthorizationError error) =>
new(false, error, null);
}
internal enum PublisherAuthorizationError
{
None = 0,
NotPublisher = 1,
ScopeMismatch = 2,
PolicyNotFound = 3,
TrustModeNotAllowed = 4,
RegionNotAllowed = 5,
ProtocolNotAllowed = 6,
AnonymousMustBeUnlisted = 7,
VisibilityNotAllowed = 8,
MetadataNotAllowed = 9,
PrincipalExpired = 10,
}
@@ -0,0 +1,144 @@
using System.Security.Cryptography;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal interface ISecretProvider
{
bool TryGetSecret(string reference, out SecretMaterial? secret);
}
internal sealed class SecretMaterial : IDisposable
{
private byte[]? _bytes;
public SecretMaterial(ReadOnlySpan<byte> bytes)
{
if (bytes.Length == 0)
{
throw new ArgumentException("Secret material cannot be empty.", nameof(bytes));
}
_bytes = bytes.ToArray();
}
public int Length => _bytes?.Length ?? 0;
public byte[] CopyBytes() => _bytes?.ToArray()
?? throw new ObjectDisposedException(nameof(SecretMaterial));
public void Dispose()
{
if (_bytes is not null)
{
CryptographicOperations.ZeroMemory(_bytes);
_bytes = null;
}
}
public override string ToString() => "[REDACTED SECRET]";
}
internal sealed class EnvironmentSecretProvider : ISecretProvider
{
private const string Prefix = "env:";
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
string? encoded = Environment.GetEnvironmentVariable(reference[Prefix.Length..]);
if (string.IsNullOrEmpty(encoded))
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
secret = new SecretMaterial(bytes);
CryptographicOperations.ZeroMemory(bytes);
return true;
}
catch (FormatException)
{
return false;
}
}
}
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
{
private const string Prefix = "development:ephemeral/";
private readonly Dictionary<string, byte[]> _secrets = new(StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
if (!_secrets.TryGetValue(reference, out byte[]? bytes))
{
bytes = RandomNumberGenerator.GetBytes(32);
_secrets.Add(reference, bytes);
}
secret = new SecretMaterial(bytes);
return true;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[EphemeralDevelopmentSecretProvider]";
}
internal sealed class DictionarySecretProvider : ISecretProvider, IDisposable
{
private readonly Dictionary<string, byte[]> _secrets;
public DictionarySecretProvider(IReadOnlyDictionary<string, byte[]> secrets) =>
_secrets = secrets.ToDictionary(
static item => item.Key,
static item => item.Value.ToArray(),
StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
if (_secrets.TryGetValue(reference, out byte[]? bytes))
{
secret = new SecretMaterial(bytes);
return true;
}
secret = null;
return false;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[DictionarySecretProvider: REDACTED]";
}
@@ -0,0 +1,275 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class SigningKeyRing : IDisposable
{
private readonly Dictionary<string, SigningKey> _keys;
private readonly ConcurrentDictionary<string, byte> _runtimeRevocations =
new(StringComparer.Ordinal);
private SigningKeyRing(Dictionary<string, SigningKey> keys) => _keys = keys;
public bool HasKeys => _keys.Count > 0;
public static SigningKeyRing Create(
IEnumerable<SigningKeyOptions> options,
ISecretProvider secretProvider)
{
SigningKeyOptions[] configuredKeys = options.ToArray();
if (configuredKeys.Length > ProvisioningLimits.MaxSigningKeys)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxSigningKeys} signing keys may be configured.");
}
Dictionary<string, SigningKey> keys = new(StringComparer.Ordinal);
try
{
foreach (SigningKeyOptions keyOptions in configuredKeys)
{
Validate(keyOptions);
if (keys.ContainsKey(keyOptions.KeyId))
{
throw new ProvisioningConfigurationException(
$"Duplicate signing key ID '{keyOptions.KeyId}'.");
}
if (keyOptions.Revoked)
{
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, null));
continue;
}
if (!secretProvider.TryGetSecret(
keyOptions.SecretReference,
out SecretMaterial? material)
|| material is null)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' has no available key material.");
}
using (material)
{
if (material.Length < 32)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' must contain at least 32 bytes.");
}
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, material.CopyBytes()));
}
}
return new SigningKeyRing(keys);
}
catch
{
foreach (SigningKey key in keys.Values)
{
key.Dispose();
}
throw;
}
}
public bool HasActiveSigningKey(DateTimeOffset now) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil);
public bool HasActiveSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId));
public bool TryGetSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId,
out SigningKey? signingKey)
{
signingKey = _keys.Values
.Where(key => !IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId))
.OrderByDescending(static key => key.NotBefore)
.ThenByDescending(static key => key.KeyId, StringComparer.Ordinal)
.FirstOrDefault();
return signingKey is not null;
}
public VerificationKeyLookup FindVerificationKey(
string keyId,
DateTimeOffset now,
out SigningKey? signingKey)
{
signingKey = null;
if (!_keys.TryGetValue(keyId, out SigningKey? candidate))
{
return VerificationKeyLookup.Unknown;
}
if (IsRevoked(candidate))
{
return VerificationKeyLookup.Revoked;
}
if (now < candidate.NotBefore)
{
return VerificationKeyLookup.NotYetValid;
}
if (now >= candidate.VerifyUntil)
{
return VerificationKeyLookup.Retired;
}
signingKey = candidate;
return VerificationKeyLookup.Available;
}
public bool Revoke(string keyId) =>
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
public void Dispose()
{
foreach (SigningKey key in _keys.Values)
{
key.Dispose();
}
_keys.Clear();
_runtimeRevocations.Clear();
}
public override string ToString() => $"[SigningKeyRing: {_keys.Count} keys, material redacted]";
private bool IsRevoked(SigningKey key) =>
key.ConfiguredRevoked || _runtimeRevocations.ContainsKey(key.KeyId);
private static void Validate(SigningKeyOptions options)
{
if (string.IsNullOrEmpty(options.KeyId)
|| options.KeyId.Length > 64
|| options.KeyId.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
throw new ProvisioningConfigurationException(
"Signing key IDs must be 164 base64url characters.");
}
if (string.IsNullOrWhiteSpace(options.SecretReference)
|| options.NotBefore >= options.SignUntil
|| options.SignUntil > options.VerifyUntil)
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' has an invalid secret reference or lifetime.");
}
if (options.CredentialKinds.Count == 0
|| options.CredentialKinds.Any(static kind => !Enum.IsDefined(kind))
|| options.CredentialKinds.Count != options.CredentialKinds.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' requires unique valid credential kinds.");
}
bool operatorKey = options.CredentialKinds.Contains(PrincipalCredentialKind.Operator);
bool hasPublisherKind = options.CredentialKinds.Any(static kind =>
kind is PrincipalCredentialKind.DedicatedPublisher
or PrincipalCredentialKind.PlayerHostGrant);
if (operatorKey
? options.CredentialKinds.Count != 1
|| options.GameId is not null
|| options.EnvironmentId is not null
: !hasPublisherKind
|| !GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' must be operator-only or bound to one game/environment.");
}
}
}
internal sealed class SigningKey : IDisposable
{
private byte[]? _material;
public SigningKey(SigningKeyOptions options, byte[]? material)
{
KeyId = options.KeyId;
NotBefore = options.NotBefore;
SignUntil = options.SignUntil;
VerifyUntil = options.VerifyUntil;
ConfiguredRevoked = options.Revoked;
CredentialKinds = options.CredentialKinds.ToFrozenSet();
GameId = options.GameId;
EnvironmentId = options.EnvironmentId;
_material = material;
}
public string KeyId { get; }
public DateTimeOffset NotBefore { get; }
public DateTimeOffset SignUntil { get; }
public DateTimeOffset VerifyUntil { get; }
public bool ConfiguredRevoked { get; }
public IReadOnlySet<PrincipalCredentialKind> CredentialKinds { get; }
public string? GameId { get; }
public string? EnvironmentId { get; }
public bool Authorizes(
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) =>
CredentialKinds.Contains(kind)
&& (kind == PrincipalCredentialKind.Operator
? gameId is null && environmentId is null
: string.Equals(GameId, gameId, StringComparison.Ordinal)
&& string.Equals(EnvironmentId, environmentId, StringComparison.Ordinal));
public byte[] Sign(string input)
{
ObjectDisposedException.ThrowIf(_material is null, this);
return HMACSHA256.HashData(_material, Encoding.ASCII.GetBytes(input));
}
public void Dispose()
{
if (_material is not null)
{
CryptographicOperations.ZeroMemory(_material);
_material = null;
}
}
public override string ToString() => $"[SigningKey {KeyId}: material redacted]";
}
internal enum VerificationKeyLookup
{
Available = 0,
Unknown = 1,
Revoked = 2,
NotYetValid = 3,
Retired = 4,
}
@@ -0,0 +1,158 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Sessions;
internal interface ISessionCapabilityService
{
string CreateDerivationSalt();
string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
}
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string CreateDerivationSalt()
{
ObjectDisposedException.ThrowIf(_disposed, this);
byte[] salt = RandomNumberGenerator.GetBytes(32);
try
{
return Encode(salt);
}
finally
{
CryptographicOperations.ZeroMemory(salt);
}
}
public string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
Span<byte> guidBytes = digest.AsSpan(0, 16);
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
return new Guid(guidBytes);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
{
fingerprint = default;
if (_disposed
|| capability is null
|| capability.Length != 43
|| capability.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;
}
byte[] digest = Derive("fingerprint", capability);
try
{
fingerprint = new SecretFingerprint(Encode(digest));
return true;
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
private byte[] Derive(params string[] segments)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
Span<byte> length = stackalloc byte[sizeof(int)];
foreach (string segment in segments)
{
ArgumentException.ThrowIfNullOrEmpty(segment);
byte[] encoded = Encoding.UTF8.GetBytes(segment);
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
hmac.AppendData(length);
hmac.AppendData(encoded);
CryptographicOperations.ZeroMemory(encoded);
}
return hmac.GetHashAndReset();
}
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
@@ -0,0 +1,452 @@
using System.Security.Cryptography;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Sessions;
internal sealed record SessionLeaseTiming(
int LeaseRenewAfterSeconds,
int HostPresenceRefreshAfterSeconds)
{
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
}
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class SessionLeaseService(
PublisherAuthorizationService authorization,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
SessionLeaseTiming timing,
IWallClock clock)
{
public SessionServiceResult<RegisterSessionResponse> Register(
AuthenticatedPrincipal principal,
RegisterSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateRegistration(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
PublisherAuthorizationResult authorized = authorization.Authorize(
principal,
request.GameId,
request.EnvironmentId,
request.RegionId,
request.ProtocolVersion,
request.Visibility,
request.Metadata,
clock.UtcNow);
if (!authorized.IsAllowed || authorized.Context is null)
{
return new(MapAuthorization(authorized.Error));
}
AuthorizedPublisherContext context = authorized.Context;
string requestFingerprint = ComputeRegistrationFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string leaseToken = capabilities.DeriveCapability(
"lease-token",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
string presenceCapability = capabilities.DeriveCapability(
"host-presence",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
{
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
}
SessionListingId listingId = new(capabilities.DeriveGuid(
"listing-id",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
LeaseId leaseId = new(capabilities.DeriveGuid(
"lease-id",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
"presence-handle",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
? context.Policy.MaxAnonymousListingsPerAddress
: context.Policy.MaxListingsPerPrincipal;
if (ownerLimit <= 0)
{
return new(RendezvousErrorCode.CapacityExceeded);
}
StoreResult<StoredListing> created = store.CreateListing(new(
request.IdempotencyKey,
requestFingerprint,
new ListingDefinition
{
ListingId = listingId,
LeaseId = leaseId,
Scope = new(context.GameId, context.EnvironmentId),
OwnerSubject = context.Subject,
RegionId = context.RegionId,
ProtocolVersion = context.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = context.Visibility,
TrustMode = context.TrustMode,
CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers,
Metadata = request.Metadata,
LeaseFingerprint = leaseFingerprint,
HostPresenceHandle = presenceHandle,
HostPresenceFingerprint = presenceFingerprint,
CapabilityDerivationSalt = derivationSalt,
},
ownerLimit), cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(MapStore(created.Code));
}
ListingDefinition persisted = created.Value.Definition;
leaseToken = capabilities.DeriveCapability(
"lease-token",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
persisted.CapabilityDerivationSalt);
presenceCapability = capabilities.DeriveCapability(
"host-presence",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
persisted.CapabilityDerivationSalt);
return new(RendezvousErrorCode.None, new RegisterSessionResponse
{
ListingId = persisted.ListingId,
LeaseId = persisted.LeaseId,
LeaseToken = leaseToken,
HostPresenceHandle = persisted.HostPresenceHandle,
HostPresenceCapability = presenceCapability,
ExpiresAt = created.Value.LeaseExpiresAt,
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
});
}
public SessionServiceResult<RenewLeaseResponse> Renew(
AuthenticatedPrincipal principal,
SessionListingId listingId,
RenewLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
RendezvousErrorCode lookup = GetAuthorizedListing(
principal,
listingId,
request.LeaseToken,
cancellationToken,
out StoredListing? listing);
if (lookup != RendezvousErrorCode.None)
{
return new(lookup);
}
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(
principal,
ownedListing,
ownedListing.Definition.Metadata);
if (!authorized.IsAllowed)
{
return new(MapAuthorization(authorized.Error));
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> renewed = store.RenewLease(new(
listingId,
ownedListing.Definition.LeaseId,
fingerprint,
ownedListing.Definition.OwnerSubject,
ownedListing.Version), cancellationToken);
return renewed.Succeeded && renewed.Value is not null
? new(RendezvousErrorCode.None, new RenewLeaseResponse
{
ExpiresAt = renewed.Value.LeaseExpiresAt,
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
})
: new(MapStore(renewed.Code));
}
public SessionServiceResult<bool> Update(
AuthenticatedPrincipal principal,
SessionListingId listingId,
UpdateSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateUpdate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
RendezvousErrorCode lookup = GetAuthorizedListing(
principal,
listingId,
request.LeaseToken,
cancellationToken,
out StoredListing? listing);
if (lookup != RendezvousErrorCode.None)
{
return new(lookup);
}
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
if (!authorized.IsAllowed)
{
return new(MapAuthorization(authorized.Error));
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> updated = store.UpdateListing(new(
listingId,
ownedListing.Definition.LeaseId,
fingerprint,
ownedListing.Definition.OwnerSubject,
request.BuildVersion,
request.DisplayName,
request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers,
request.Metadata), cancellationToken);
return updated.Succeeded
? new(RendezvousErrorCode.None, true)
: new(MapStore(updated.Code));
}
public SessionServiceResult<bool> Delete(
AuthenticatedPrincipal principal,
SessionListingId listingId,
DeleteSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
if (principal is not IPublisherPrincipal publisher
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
{
return new(RendezvousErrorCode.Forbidden);
}
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return found.Code == StoreResultCode.ServiceUnavailable
? new(RendezvousErrorCode.ServiceUnavailable)
: new(RendezvousErrorCode.None, true);
}
StoreResult<bool> deleted = store.DeleteListing(new(
listingId,
found.Value.Definition.LeaseId,
fingerprint,
publisher.Subject), cancellationToken);
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
? new(RendezvousErrorCode.None, true)
: new(MapStore(deleted.Code));
}
private RendezvousErrorCode GetAuthorizedListing(
AuthenticatedPrincipal principal,
SessionListingId listingId,
string leaseToken,
CancellationToken cancellationToken,
out StoredListing? listing)
{
listing = null;
if (principal is not IPublisherPrincipal publisher)
{
return RendezvousErrorCode.Forbidden;
}
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
{
return RendezvousErrorCode.NotFound;
}
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return MapStore(found.Code);
}
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|| found.Value.Definition.LeaseFingerprint != fingerprint)
{
return RendezvousErrorCode.NotFound;
}
listing = found.Value;
return RendezvousErrorCode.None;
}
private PublisherAuthorizationResult AuthorizeExisting(
AuthenticatedPrincipal principal,
StoredListing listing,
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
principal,
listing.Definition.Scope.GameId,
listing.Definition.Scope.EnvironmentId,
listing.Definition.RegionId,
listing.Definition.ProtocolVersion,
listing.Definition.Visibility,
metadata,
clock.UtcNow);
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|| string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| string.IsNullOrEmpty(request.RegionId.Value)
|| request.ProtocolVersion == 0
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !Enum.IsDefined(request.Visibility)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
{
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (lease != RendezvousErrorCode.None)
{
return lease;
}
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
? RendezvousErrorCode.None
: RendezvousErrorCode.InvalidRequest;
}
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
{
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
PublisherAuthorizationError.RegionNotAllowed
or PublisherAuthorizationError.VisibilityNotAllowed
or PublisherAuthorizationError.AnonymousMustBeUnlisted
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
_ => RendezvousErrorCode.Forbidden,
};
private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch
{
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
StoreResultCode.Expired => RendezvousErrorCode.Expired,
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
};
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
{
RegisterSessionRequest canonical = new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = request.Visibility,
Capacity = new SessionCapacity
{
CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers,
},
Metadata = request.Metadata
.OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
CryptographicOperations.ZeroMemory(encoded);
try
{
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
}
@@ -0,0 +1,325 @@
using System.Collections.Frozen;
using System.Diagnostics;
using System.Net;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.State;
internal interface IWallClock
{
DateTimeOffset UtcNow { get; }
}
internal interface IMonotonicClock
{
TimeSpan Elapsed { get; }
}
internal sealed class SystemRendezvousClock : IWallClock, IMonotonicClock
{
private readonly long _origin = Stopwatch.GetTimestamp();
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_origin);
}
internal sealed record EphemeralStoreOptions
{
public int MaxListings { get; init; } = 25_000;
public int MaxPresenceBindings { get; init; } = 25_000;
public int MaxJoinAttempts { get; init; } = 10_000;
public int MaxReplayEntries { get; init; } = 30_000;
public int MaxRevocations { get; init; } = 10_000;
public int MaxIdempotencyEntries { get; init; } = 35_000;
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
public void Validate()
{
RequirePositive(MaxListings, nameof(MaxListings));
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
RequirePositive(MaxRevocations, nameof(MaxRevocations));
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
{
throw new ArgumentOutOfRangeException(
nameof(IdempotencyLifetime),
"Idempotency retention must cover every idempotent resource lifetime.");
}
}
private static void RequirePositive(int value, string name)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(name, "Store capacity must be positive.");
}
}
private static void RequireDuration(TimeSpan value, TimeSpan maximum, string name)
{
if (value <= TimeSpan.Zero || value > maximum)
{
throw new ArgumentOutOfRangeException(name, $"Duration must be positive and no greater than {maximum}.");
}
}
}
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
{
private readonly string? _value;
public SecretFingerprint(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
{
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
}
_value = value;
}
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
public bool Equals(SecretFingerprint other)
{
ReadOnlySpan<char> left = _value.AsSpan();
ReadOnlySpan<char> right = other._value.AsSpan();
if (left.Length != right.Length)
{
return false;
}
int difference = 0;
for (int index = 0; index < left.Length; index++)
{
difference |= left[index] ^ right[index];
}
return difference == 0;
}
public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty);
public override string ToString() => "[REDACTED]";
public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right);
public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right);
}
internal readonly record struct ObservedEndpoint
{
public ObservedEndpoint(AddressFamilyKind addressFamily, string address, int port)
{
if (!IPAddress.TryParse(address, out IPAddress? parsed)
|| (addressFamily == AddressFamilyKind.Ipv4 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|| (addressFamily == AddressFamilyKind.Ipv6 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6))
{
throw new ArgumentException("The address must match the declared address family.", nameof(address));
}
if (port is < 1 or > 65_535)
{
throw new ArgumentOutOfRangeException(nameof(port));
}
AddressFamily = addressFamily;
Address = parsed.ToString();
Port = port;
}
public AddressFamilyKind AddressFamily { get; }
public string Address { get; }
public int Port { get; }
public bool IsValid => !string.IsNullOrEmpty(Address)
&& Port is >= 1 and <= 65_535
&& AddressFamily is AddressFamilyKind.Ipv4 or AddressFamilyKind.Ipv6;
}
internal sealed record ListingDefinition
{
public required SessionListingId ListingId { get; init; }
public required LeaseId LeaseId { get; init; }
public required TenantScope Scope { get; init; }
public required string OwnerSubject { get; init; }
public required RegionId RegionId { get; init; }
public required uint ProtocolVersion { get; init; }
public required string BuildVersion { get; init; }
public required string DisplayName { get; init; }
public required ListingVisibility Visibility { get; init; }
public required PublisherTrustMode TrustMode { get; init; }
public required int CurrentPlayers { get; init; }
public required int MaximumPlayers { get; init; }
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
public required SecretFingerprint LeaseFingerprint { get; init; }
public required MediationHandle HostPresenceHandle { get; init; }
public required SecretFingerprint HostPresenceFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; }
}
internal sealed record StoredListing
{
public required ListingDefinition Definition { get; init; }
public required DateTimeOffset LeaseExpiresAt { get; init; }
public required long Version { get; init; }
public required bool HasFreshPresence { get; init; }
public static ListingDefinition Freeze(ListingDefinition source) => source with
{
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
};
}
internal sealed record CreateListingCommand(
string IdempotencyKey,
string RequestFingerprint,
ListingDefinition Listing,
int OwnerListingLimit = int.MaxValue);
internal sealed record RenewLeaseCommand(
SessionListingId ListingId,
LeaseId LeaseId,
SecretFingerprint LeaseFingerprint,
string OwnerSubject,
long ExpectedVersion);
internal sealed record UpdateListingCommand(
SessionListingId ListingId,
LeaseId LeaseId,
SecretFingerprint LeaseFingerprint,
string OwnerSubject,
string BuildVersion,
string DisplayName,
int CurrentPlayers,
int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata);
internal sealed record DeleteListingCommand(
SessionListingId ListingId,
LeaseId LeaseId,
SecretFingerprint LeaseFingerprint,
string OwnerSubject);
internal sealed record BindHostPresenceCommand(
MediationHandle Handle,
SecretFingerprint CapabilityFingerprint,
ObservedEndpoint PublicEndpoint,
ObservedEndpoint? LocalEndpoint);
internal sealed record VisibleListingQuery(
TenantScope Scope,
uint ProtocolVersion,
RegionId? RegionId,
int MaximumResults = ContractLimits.BrowserPageMaxItems,
SessionListingId? AfterListingId = null,
bool ExcludeFull = false);
internal enum AttemptPeerRole
{
Host = 1,
Client = 2,
}
internal sealed record CreateJoinAttemptCommand
{
public required string IdempotencyOwner { get; init; }
public required string IdempotencyKey { get; init; }
public required string RequestFingerprint { get; init; }
public required string ClientSubject { get; init; }
public required JoinAttemptId AttemptId { get; init; }
public required MediationHandle MediationHandle { get; init; }
public required TenantScope Scope { get; init; }
public required SessionListingId ListingId { get; init; }
public required uint ProtocolVersion { get; init; }
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
}
internal sealed record AttemptEndpointBinding(
ObservedEndpoint PublicEndpoint,
ObservedEndpoint? LocalEndpoint);
internal sealed record StoredJoinAttempt
{
public required JoinAttemptId AttemptId { get; init; }
public required MediationHandle MediationHandle { get; init; }
public required TenantScope Scope { get; init; }
public required SessionListingId ListingId { get; init; }
public required string ClientSubject { get; init; }
public required uint ProtocolVersion { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; }
public AttemptEndpointBinding? ClientEndpoint { get; init; }
public required bool IntroductionConsumed { get; init; }
}
internal sealed record BindAttemptEndpointCommand(
MediationHandle Handle,
AttemptPeerRole Role,
SecretFingerprint CapabilityFingerprint,
ObservedEndpoint PublicEndpoint,
ObservedEndpoint? LocalEndpoint);
internal sealed record IntroductionEndpoints(
JoinAttemptId AttemptId,
AttemptEndpointBinding Host,
AttemptEndpointBinding Client);
internal sealed record ReplayConsumption(
string Namespace,
string Key,
TimeSpan? Lifetime = null);
internal enum StoreResultCode
{
Success = 0,
NotFound = 1,
Expired = 2,
Revoked = 3,
Conflict = 4,
CapacityExceeded = 5,
Draining = 6,
ReplayRejected = 7,
ServiceUnavailable = 8,
}
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
{
public bool Succeeded => Code == StoreResultCode.Success;
}
internal interface IEphemeralRendezvousStore
{
Guid InstanceId { get; }
bool IsAvailable { get; }
bool IsDraining { get; }
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredListing> RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredListing> UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default);
StoreResult<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
void BeginDrain(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,867 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.State;
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
{
private readonly object _gate = new();
private readonly EphemeralStoreOptions _options;
private readonly IMonotonicClock _monotonicClock;
private readonly DateTimeOffset _wallOrigin;
private readonly TimeSpan _monotonicOrigin;
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
private TimeSpan? _drainDeadline;
private bool _available = true;
public InMemoryEphemeralRendezvousStore(
EphemeralStoreOptions options,
IWallClock wallClock,
IMonotonicClock monotonicClock)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(wallClock);
ArgumentNullException.ThrowIfNull(monotonicClock);
options.Validate();
_options = options;
_monotonicClock = monotonicClock;
_wallOrigin = wallClock.UtcNow;
_monotonicOrigin = monotonicClock.Elapsed;
InstanceId = Guid.NewGuid();
}
public Guid InstanceId { get; }
public bool IsAvailable
{
get
{
lock (_gate)
{
return _available;
}
}
}
public bool IsDraining
{
get
{
lock (_gate)
{
return _drainDeadline.HasValue;
}
}
}
public StoreResult<StoredListing> CreateListing(
CreateListingCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
{
ArgumentNullException.ThrowIfNull(command);
ValidateListing(command.Listing);
if (command.OwnerListingLimit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(command), "Owner listing limit must be positive.");
}
ValidateIdempotency(command.IdempotencyKey, command.RequestFingerprint);
StoreResult<StoredListing>? admission = CheckNewWorkAdmission<StoredListing>(command.Listing.OwnerSubject);
if (admission is not null)
{
return admission;
}
string idempotencyKey = $"listing:{command.Listing.Scope.GameId}:{command.Listing.Scope.EnvironmentId}:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
{
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
{
return new(StoreResultCode.Conflict);
}
if (previous.ResourceId is SessionListingId listingId
&& _listings.TryGetValue(listingId, out ListingEntry? existing))
{
return new(StoreResultCode.Success, Snapshot(existing), true);
}
return new(StoreResultCode.Expired);
}
if (_listings.Count >= _options.MaxListings
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|| _listings.Values.Count(entry => string.Equals(
entry.Definition.OwnerSubject,
command.Listing.OwnerSubject,
StringComparison.Ordinal)) >= command.OwnerListingLimit)
{
return new(StoreResultCode.CapacityExceeded);
}
ListingDefinition frozen = StoredListing.Freeze(command.Listing);
if (_listings.ContainsKey(frozen.ListingId)
|| _leases.ContainsKey(frozen.LeaseId)
|| HandleExists(frozen.HostPresenceHandle))
{
return new(StoreResultCode.Conflict);
}
ListingEntry entry = new(
frozen,
now + _options.LeaseLifetime,
WallDeadline(now, _options.LeaseLifetime),
version: 1);
_listings.Add(frozen.ListingId, entry);
_leases.Add(frozen.LeaseId, frozen.ListingId);
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
_idempotency.Add(idempotencyKey, new(
command.RequestFingerprint,
frozen.ListingId,
now + _options.IdempotencyLifetime));
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
public StoreResult<StoredListing> RenewLease(
RenewLeaseCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
{
ArgumentNullException.ThrowIfNull(command);
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (_drainDeadline.HasValue)
{
return new(StoreResultCode.Draining);
}
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry))
{
return new(StoreResultCode.NotFound);
}
if (entry.Definition.LeaseId != command.LeaseId
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
{
return new(StoreResultCode.NotFound);
}
if (entry.Version != command.ExpectedVersion)
{
return new(StoreResultCode.Conflict, Snapshot(entry));
}
entry.LeaseDeadline = now + _options.LeaseLifetime;
entry.WallExpiresAt = WallDeadline(now, _options.LeaseLifetime);
entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
public StoreResult<StoredListing> UpdateListing(
UpdateListingCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
ValidateSubject(command.OwnerSubject, nameof(command.OwnerSubject));
if (!ContractValidation.IsBuildVersionValid(command.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(command.DisplayName)
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers
|| !ContractValidation.IsMetadataValid(command.Metadata))
{
throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (_drainDeadline.HasValue)
{
return new(StoreResultCode.Draining);
}
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|| entry.Definition.LeaseId != command.LeaseId
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
{
return new(StoreResultCode.NotFound);
}
entry.Definition = StoredListing.Freeze(entry.Definition with
{
BuildVersion = command.BuildVersion,
DisplayName = command.DisplayName,
CurrentPlayers = command.CurrentPlayers,
MaximumPlayers = command.MaximumPlayers,
Metadata = command.Metadata,
});
entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
public StoreResult<bool> DeleteListing(
DeleteListingCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|| entry.Definition.LeaseId != command.LeaseId
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
{
return new(StoreResultCode.NotFound);
}
RemoveListing(command.ListingId);
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<StoredListing> GetListing(
SessionListingId listingId,
bool requireFreshPresence,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
{
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_listings.TryGetValue(listingId, out ListingEntry? entry))
{
return new(StoreResultCode.NotFound);
}
bool fresh = _presence.ContainsKey(entry.Definition.HostPresenceHandle);
return requireFreshPresence && !fresh
? new(StoreResultCode.NotFound)
: new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
public StoreResult<StoredListing> BindHostPresence(
BindHostPresenceCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.Handle.Value == Guid.Empty || !command.CapabilityFingerprint.IsValid)
{
throw new ArgumentException("Host presence binding is invalid.", nameof(command));
}
ValidateEndpoint(command.PublicEndpoint, command.LocalEndpoint);
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
if (!_presence.ContainsKey(command.Handle) && _presence.Count >= _options.MaxPresenceBindings)
{
return new(StoreResultCode.CapacityExceeded);
}
_presence[command.Handle] = new(
command.PublicEndpoint,
command.LocalEndpoint,
now + _options.PresenceLifetime);
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
VisibleListingQuery query,
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredListing>>(_ =>
{
ArgumentNullException.ThrowIfNull(query);
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!IsScopeValid(query.Scope)
|| query.ProtocolVersion == 0
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|| query.MaximumResults <= 0
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
{
throw new ArgumentOutOfRangeException(nameof(query));
}
IReadOnlyList<StoredListing> visible = _listings.Values
.Where(entry => entry.Definition.Scope == query.Scope
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
&& entry.Definition.Visibility == ListingVisibility.Public
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
&& (!query.AfterListingId.HasValue
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
&& (!query.ExcludeFull
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
.OrderBy(static entry => entry.Definition.ListingId.Value)
.Take(query.MaximumResults)
.Select(Snapshot)
.ToArray();
return new(StoreResultCode.Success, visible);
}, cancellationToken);
public StoreResult<StoredJoinAttempt> CreateJoinAttempt(
CreateJoinAttemptCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
{
ArgumentNullException.ThrowIfNull(command);
ValidateAttempt(command);
ValidateIdempotency(command.IdempotencyKey, command.RequestFingerprint);
ValidateSubject(command.IdempotencyOwner, nameof(command.IdempotencyOwner));
ValidateSubject(command.ClientSubject, nameof(command.ClientSubject));
StoreResult<StoredJoinAttempt>? admission = CheckNewWorkAdmission<StoredJoinAttempt>(command.ClientSubject);
if (admission is not null)
{
return admission;
}
string idempotencyKey = $"attempt:{command.Scope.GameId}:{command.Scope.EnvironmentId}:{command.IdempotencyOwner}:{command.IdempotencyKey}";
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
{
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
{
return new(StoreResultCode.Conflict);
}
if (previous.ResourceId is JoinAttemptId attemptId
&& _attempts.TryGetValue(attemptId, out AttemptEntry? priorAttempt))
{
return new(StoreResultCode.Success, Snapshot(priorAttempt), true);
}
return new(StoreResultCode.Expired);
}
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|| listing.Definition.Scope != command.Scope
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
{
return new(StoreResultCode.NotFound);
}
if (_attempts.Count >= _options.MaxJoinAttempts
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
>= command.ScopeAttemptLimit)
{
return new(StoreResultCode.CapacityExceeded);
}
if (_attempts.ContainsKey(command.AttemptId) || HandleExists(command.MediationHandle))
{
return new(StoreResultCode.Conflict);
}
AttemptEntry attempt = new(
command,
now + _options.JoinAttemptLifetime,
WallDeadline(now, _options.JoinAttemptLifetime));
_attempts.Add(command.AttemptId, attempt);
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
_idempotency.Add(idempotencyKey, new(
command.RequestFingerprint,
command.AttemptId,
now + _options.IdempotencyLifetime));
return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken);
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.Handle.Value == Guid.Empty
|| command.Role is not (AttemptPeerRole.Host or AttemptPeerRole.Client)
|| !command.CapabilityFingerprint.IsValid)
{
throw new ArgumentException("Attempt endpoint binding is invalid.", nameof(command));
}
ValidateEndpoint(command.PublicEndpoint, command.LocalEndpoint);
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
{
return new(StoreResultCode.NotFound);
}
SecretFingerprint expected = command.Role == AttemptPeerRole.Host
? attempt.HostCapabilityFingerprint
: attempt.ClientCapabilityFingerprint;
if (expected != command.CapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
AttemptEndpointBinding binding = new(command.PublicEndpoint, command.LocalEndpoint);
AttemptEndpointBinding? current = command.Role == AttemptPeerRole.Host
? attempt.HostEndpoint
: attempt.ClientEndpoint;
if (current is not null)
{
return current == binding
? new(StoreResultCode.Success, Snapshot(attempt), true)
: new(StoreResultCode.ReplayRejected);
}
if (command.Role == AttemptPeerRole.Host)
{
attempt.HostEndpoint = binding;
}
else
{
attempt.ClientEndpoint = binding;
}
return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken);
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
MediationHandle handle,
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ =>
{
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
{
return new(StoreResultCode.NotFound);
}
if (attempt.IntroductionConsumed)
{
return new(StoreResultCode.ReplayRejected);
}
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
{
return new(StoreResultCode.Conflict);
}
attempt.IntroductionConsumed = true;
return new(StoreResultCode.Success, new(
attempt.Command.AttemptId,
attempt.HostEndpoint,
attempt.ClientEndpoint));
}, cancellationToken);
public StoreResult<bool> ConsumeReplay(
ReplayConsumption consumption,
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
{
ArgumentNullException.ThrowIfNull(consumption);
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
ValidateReplay(consumption);
string key = $"{consumption.Namespace}:{consumption.Key}";
if (_replay.ContainsKey(key))
{
return new(StoreResultCode.ReplayRejected);
}
if (_replay.Count >= _options.MaxReplayEntries)
{
return new(StoreResultCode.CapacityExceeded);
}
TimeSpan lifetime = consumption.Lifetime ?? _options.ReplayLifetime;
if (lifetime <= TimeSpan.Zero || lifetime > _options.ReplayLifetime)
{
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
}
_replay.Add(key, now + lifetime);
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<bool> RevokeListing(
SessionListingId listingId,
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
{
if (!_listings.ContainsKey(listingId))
{
return new(StoreResultCode.NotFound);
}
RemoveListing(listingId);
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<int> RevokePrincipal(
string subject,
TimeSpan lifetime,
CancellationToken cancellationToken = default) => Atomic<int>(now =>
{
ValidateSubject(subject, nameof(subject));
if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromMinutes(10))
{
throw new ArgumentOutOfRangeException(nameof(lifetime));
}
if (!_revocations.ContainsKey(subject) && _revocations.Count >= _options.MaxRevocations)
{
return new(StoreResultCode.CapacityExceeded);
}
_revocations[subject] = now + lifetime;
SessionListingId[] listings = _listings
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
.ToArray();
JoinAttemptId[] attempts = _attempts
.Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
.ToArray();
foreach (SessionListingId listingId in listings)
{
RemoveListing(listingId);
}
foreach (JoinAttemptId attemptId in attempts)
{
RemoveAttempt(attemptId);
}
return new(StoreResultCode.Success, listings.Length + attempts.Length);
}, cancellationToken);
public void BeginDrain(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_drainDeadline.HasValue)
{
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
}
}
}
internal void MarkUnavailable()
{
lock (_gate)
{
_available = false;
ClearActiveState();
}
}
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
cancellationToken.ThrowIfCancellationRequested();
TimeSpan now = _monotonicClock.Elapsed;
Cleanup(now);
return operation(now);
}
}
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
{
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (_drainDeadline.HasValue)
{
return new(StoreResultCode.Draining);
}
return _revocations.ContainsKey(subject)
? new(StoreResultCode.Revoked)
: null;
}
private void Cleanup(TimeSpan now)
{
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
{
ClearActiveState();
}
RemoveExpired(_revocations, now);
RemoveExpired(_replay, now);
foreach (string key in _idempotency
.Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key)
.ToArray())
{
_idempotency.Remove(key);
}
foreach (MediationHandle handle in _presence
.Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key)
.ToArray())
{
_presence.Remove(handle);
}
foreach (JoinAttemptId attemptId in _attempts
.Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key)
.ToArray())
{
RemoveAttempt(attemptId);
}
foreach (SessionListingId listingId in _listings
.Where(item => item.Value.LeaseDeadline <= now)
.Select(static item => item.Key)
.ToArray())
{
RemoveListing(listingId);
}
}
private void ClearActiveState()
{
_listings.Clear();
_leases.Clear();
_presenceHandles.Clear();
_presence.Clear();
_attempts.Clear();
_attemptHandles.Clear();
_idempotency.Clear();
_replay.Clear();
}
private void RemoveListing(SessionListingId listingId)
{
if (!_listings.Remove(listingId, out ListingEntry? listing))
{
return;
}
_leases.Remove(listing.Definition.LeaseId);
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
_presence.Remove(listing.Definition.HostPresenceHandle);
foreach (JoinAttemptId attemptId in _attempts
.Where(item => item.Value.Command.ListingId == listingId)
.Select(static item => item.Key)
.ToArray())
{
RemoveAttempt(attemptId);
}
}
private void RemoveAttempt(JoinAttemptId attemptId)
{
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
{
_attemptHandles.Remove(attempt.Command.MediationHandle);
}
}
private bool HandleExists(MediationHandle handle) =>
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
private DateTimeOffset WallDeadline(TimeSpan now, TimeSpan lifetime) =>
_wallOrigin + (now - _monotonicOrigin) + lifetime;
private StoredListing Snapshot(ListingEntry entry) => new()
{
Definition = entry.Definition,
LeaseExpiresAt = entry.WallExpiresAt,
Version = entry.Version,
HasFreshPresence = _presence.ContainsKey(entry.Definition.HostPresenceHandle),
};
private static StoredJoinAttempt Snapshot(AttemptEntry entry) => new()
{
AttemptId = entry.Command.AttemptId,
MediationHandle = entry.Command.MediationHandle,
Scope = entry.Command.Scope,
ListingId = entry.Command.ListingId,
ClientSubject = entry.Command.ClientSubject,
ProtocolVersion = entry.Command.ProtocolVersion,
ExpiresAt = entry.WallExpiresAt,
HostEndpoint = entry.HostEndpoint,
ClientEndpoint = entry.ClientEndpoint,
IntroductionConsumed = entry.IntroductionConsumed,
};
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
{
foreach (string key in entries
.Where(item => item.Value <= now)
.Select(static item => item.Key)
.ToArray())
{
entries.Remove(key);
}
}
private static void ValidateListing(ListingDefinition listing)
{
ArgumentNullException.ThrowIfNull(listing);
ValidateSubject(listing.OwnerSubject, nameof(listing.OwnerSubject));
ArgumentNullException.ThrowIfNull(listing.Metadata);
if (listing.ListingId.Value == Guid.Empty
|| listing.LeaseId.Value == Guid.Empty
|| listing.HostPresenceHandle.Value == Guid.Empty
|| !IsScopeValid(listing.Scope)
|| string.IsNullOrEmpty(listing.RegionId.Value)
|| listing.ProtocolVersion == 0
|| !ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(listing.DisplayName)
|| !Enum.IsDefined(listing.Visibility)
|| !Enum.IsDefined(listing.TrustMode)
|| listing.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| listing.CurrentPlayers < 0
|| listing.CurrentPlayers > listing.MaximumPlayers
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|| !listing.LeaseFingerprint.IsValid
|| !listing.HostPresenceFingerprint.IsValid
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
{
throw new ArgumentException("Listing invariants are invalid.", nameof(listing));
}
}
private static void ValidateIdempotency(string key, string requestFingerprint)
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 128)
{
throw new ArgumentException("Idempotency keys must contain 1-128 characters.", nameof(key));
}
if (string.IsNullOrWhiteSpace(requestFingerprint) || requestFingerprint.Length > 128)
{
throw new ArgumentException("Request fingerprints must contain 1-128 characters.", nameof(requestFingerprint));
}
}
private static void ValidateReplay(ReplayConsumption consumption)
{
if (string.IsNullOrWhiteSpace(consumption.Namespace) || consumption.Namespace.Length > 64
|| string.IsNullOrWhiteSpace(consumption.Key) || consumption.Key.Length > 128)
{
throw new ArgumentException("Replay namespace/key is invalid.", nameof(consumption));
}
}
private static void ValidateAttempt(CreateJoinAttemptCommand command)
{
if (command.AttemptId.Value == Guid.Empty
|| command.MediationHandle.Value == Guid.Empty
|| command.ListingId.Value == Guid.Empty
|| !IsScopeValid(command.Scope)
|| command.ProtocolVersion == 0
|| !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid
|| command.ScopeAttemptLimit <= 0)
{
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
}
}
private static void ValidateEndpoint(ObservedEndpoint publicEndpoint, ObservedEndpoint? localEndpoint)
{
if (!publicEndpoint.IsValid || (localEndpoint.HasValue && !localEndpoint.Value.IsValid))
{
throw new ArgumentException("Observed endpoints must be valid immutable endpoint values.", nameof(publicEndpoint));
}
}
private static bool IsScopeValid(TenantScope scope) =>
!string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value);
private static bool IsDerivationSaltValid(string? value) => value is not null
&& value.Length == 43
&& value.All(static character =>
character is >= 'A' and <= 'Z'
or >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-'
or '_');
private static void ValidateSubject(string subject, string parameterName)
{
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256)
{
throw new ArgumentException("Subjects must contain 1-256 characters.", parameterName);
}
}
private sealed class ListingEntry(
ListingDefinition definition,
TimeSpan leaseDeadline,
DateTimeOffset wallExpiresAt,
long version)
{
public ListingDefinition Definition { get; set; } = definition;
public TimeSpan LeaseDeadline { get; set; } = leaseDeadline;
public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt;
public long Version { get; set; } = version;
}
private sealed class PresenceEntry(
ObservedEndpoint publicEndpoint,
ObservedEndpoint? localEndpoint,
TimeSpan deadline)
{
public ObservedEndpoint PublicEndpoint { get; } = publicEndpoint;
public ObservedEndpoint? LocalEndpoint { get; } = localEndpoint;
public TimeSpan Deadline { get; } = deadline;
}
private sealed class AttemptEntry(
CreateJoinAttemptCommand command,
TimeSpan deadline,
DateTimeOffset wallExpiresAt)
{
public CreateJoinAttemptCommand Command { get; } = command;
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
public TimeSpan Deadline { get; } = deadline;
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
public AttemptEndpointBinding? HostEndpoint { get; set; }
public AttemptEndpointBinding? ClientEndpoint { get; set; }
public bool IntroductionConsumed { get; set; }
}
private sealed record IdempotencyEntry(
string RequestFingerprint,
object ResourceId,
TimeSpan Deadline);
}
@@ -1,5 +1,8 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport; namespace FinalFactory.Rendezvous.Server.Transport;
@@ -7,10 +10,12 @@ namespace FinalFactory.Rendezvous.Server.Transport;
/// <summary> /// <summary>
/// Owns the cancellable UDP socket used by the future NAT mediator. /// Owns the cancellable UDP socket used by the future NAT mediator.
/// </summary> /// </summary>
public sealed partial class UdpMediatorService : BackgroundService internal sealed partial class UdpMediatorService : BackgroundService
{ {
private readonly ILogger<UdpMediatorService> _logger; private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options; private readonly UdpMediatorOptions _options;
private readonly IEphemeralRendezvousStore _store;
private readonly ISessionCapabilityService _capabilities;
private UdpClient? _udpClient; private UdpClient? _udpClient;
/// <summary> /// <summary>
@@ -18,10 +23,14 @@ public sealed partial class UdpMediatorService : BackgroundService
/// </summary> /// </summary>
public UdpMediatorService( public UdpMediatorService(
IOptions<UdpMediatorOptions> options, IOptions<UdpMediatorOptions> options,
ILogger<UdpMediatorService> logger) ILogger<UdpMediatorService> logger,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities)
{ {
_options = options.Value; _options = options.Value;
_logger = logger; _logger = logger;
_store = store;
_capabilities = capabilities;
} }
/// <summary> /// <summary>
@@ -81,7 +90,10 @@ public sealed partial class UdpMediatorService : BackgroundService
{ {
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false); UdpReceiveResult received = await udpClient
.ReceiveAsync(stoppingToken)
.ConfigureAwait(false);
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11. // Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
} }
} }
@@ -99,6 +111,53 @@ public sealed partial class UdpMediatorService : BackgroundService
} }
} }
internal UdpPresenceProcessingResult ProcessDatagram(
ReadOnlySpan<byte> encoded,
IPEndPoint observedSource,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(observedSource);
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|| datagram is null
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
{
return UdpPresenceProcessingResult.Dropped;
}
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
{
return UdpPresenceProcessingResult.ClientPresenceDeferred;
}
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => 0,
};
if (publicFamily == 0)
{
return UdpPresenceProcessingResult.Dropped;
}
ObservedEndpoint publicEndpoint = new(
publicFamily,
observedSource.Address.ToString(),
observedSource.Port);
ObservedEndpoint localEndpoint = new(
datagram.AddressFamily,
datagram.LocalAddress,
datagram.LocalPort);
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
datagram.MediationHandle,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
return bound.Succeeded
? UdpPresenceProcessingResult.HostPresenceAccepted
: UdpPresenceProcessingResult.HostPresenceRejected;
}
[LoggerMessage( [LoggerMessage(
EventId = 1, EventId = 1,
Level = LogLevel.Information, Level = LogLevel.Information,
@@ -114,3 +173,11 @@ public sealed partial class UdpMediatorService : BackgroundService
Message = "UDP mediator stopped")] Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger); private static partial void LogMediatorStopped(ILogger logger);
} }
internal enum UdpPresenceProcessingResult
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
ClientPresenceDeferred = 3,
}
@@ -0,0 +1,43 @@
{
"Rendezvous": {
"Provisioning": {
"Issuer": "final-factory-rendezvous-development",
"Audience": "final-factory-rendezvous",
"ClockSkewSeconds": 30,
"SigningKeys": [
{
"KeyId": "development-ephemeral-1",
"SecretReference": "development:ephemeral/rendezvous-signing",
"CredentialKinds": ["DedicatedPublisher", "PlayerHostGrant"],
"GameId": "space-game",
"EnvironmentId": "development",
"NotBefore": "2025-01-01T00:00:00Z",
"SignUntil": "2035-01-01T00:00:00Z",
"VerifyUntil": "2035-01-02T00:00:00Z"
}
],
"Games": [
{
"GameId": "space-game",
"EnvironmentId": "development",
"Enabled": true,
"ProtocolVersions": [1],
"Regions": ["local"],
"VisibilityModes": ["Public", "Unlisted"],
"PublisherTrustModes": ["ManagedDedicated", "PlayerGrant", "AnonymousUnlisted"],
"MetadataValueMaxBytes": {
"map": 64,
"mode": 32
},
"RequiredMetadataKeys": [],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 2,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 1,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "Disabled"
}
]
}
}
}
@@ -0,0 +1,153 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.Browser;
public sealed class SessionBrowserServiceTests
{
[Fact]
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
{
using SessionBrowserFixture fixture = new();
StoredListing eligible = fixture.Add();
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
fixture.Add(protocolVersion: 8);
fixture.Add(regionId: new("us-east"));
fixture.Add(visibility: ListingVisibility.Unlisted);
fixture.Add(fresh: false);
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
BrowseSessionsRequest request = fixture.Request();
request.ExcludeFull = true;
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
Assert.True(result.Succeeded);
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
}
[Fact]
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
{
using SessionBrowserFixture fixture = new();
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
Assert.True(fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Succeeded);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
new("other-game"),
fixture.Scope.EnvironmentId,
7).Error);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
8).Error);
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Error);
}
[Fact]
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
{
using SessionBrowserFixture fixture = new();
for (int index = 0; index < 7; index++)
{
fixture.Add();
}
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
List<SessionListingId> seen = [];
do
{
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
seen.AddRange(page.Items.Select(static item => item.ListingId));
request.Cursor = page.NextCursor;
}
while (request.Cursor is not null);
Assert.Equal(7, seen.Count);
Assert.Equal(7, seen.Distinct().Count());
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
rescoped.ExcludeFull = true;
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
}
[Fact]
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
{
using SessionBrowserFixture fixture = new();
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
static index => $"key-{index}",
static index => new string((char)('a' + index % 26), 256),
EqualityComparer<string>.Default);
for (int index = 0; index < 100; index++)
{
fixture.Add(metadata: metadata);
}
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
Assert.NotEmpty(response.Items);
Assert.NotNull(response.NextCursor);
Assert.True(response.Items.Count < 100);
}
[Fact]
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
{
using SessionBrowserFixture fixture = new();
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "<script>alert(1)</script>",
});
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
string json = JsonSerializer.Serialize(response, ContractJson.Options);
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
}
[Fact]
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
{
using SessionBrowserFixture fixture = new();
StoredListing listing = fixture.Add();
fixture.Store.RevokeListing(listing.Definition.ListingId);
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
listing.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Error);
}
}
@@ -0,0 +1,71 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.State;
namespace FinalFactory.Rendezvous.Tests.Browser;
internal sealed class SessionBrowserFixture : IDisposable
{
private readonly EphemeralStateFixture _state = new();
public SessionBrowserFixture()
{
Cursors = new();
Browser = new(_state.Store, Cursors, _state.Clock);
}
public InMemoryEphemeralRendezvousStore Store => _state.Store;
public ManualRendezvousClock Clock => _state.Clock;
public SessionBrowserCursorCodec Cursors { get; }
public SessionBrowserService Browser { get; }
public TenantScope Scope => _state.Scope;
public StoredListing Add(
TenantScope? scope = null,
uint protocolVersion = 7,
RegionId? regionId = null,
ListingVisibility visibility = ListingVisibility.Public,
bool fresh = true,
int currentPlayers = 1,
int maximumPlayers = 8,
IReadOnlyDictionary<string, string>? metadata = null)
{
CreateListingCommand seed = _state.ListingCommand();
CreateListingCommand command = seed with
{
Listing = seed.Listing with
{
Scope = scope ?? Scope,
ProtocolVersion = protocolVersion,
RegionId = regionId ?? seed.Listing.RegionId,
Visibility = visibility,
CurrentPlayers = currentPlayers,
MaximumPlayers = maximumPlayers,
Metadata = metadata ?? seed.Listing.Metadata,
},
};
StoredListing listing = Store.CreateListing(command).Value!;
if (fresh)
{
listing = Store.BindHostPresence(new(
command.Listing.HostPresenceHandle,
command.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000),
null)).Value!;
}
return listing;
}
public BrowseSessionsRequest Request(int pageSize = 100) => new()
{
GameId = Scope.GameId,
EnvironmentId = Scope.EnvironmentId,
ProtocolVersion = 7,
RegionId = new("eu-central"),
PageSize = pageSize,
};
public void Dispose() => Cursors.Dispose();
}
@@ -4,6 +4,15 @@ namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractLimitTests public sealed class ContractLimitTests
{ {
[Fact]
public void RequiredPlayerFacingTextRejectsEmptyOrWhitespaceValues()
{
Assert.False(ContractValidation.IsBuildVersionValid(string.Empty));
Assert.False(ContractValidation.IsBuildVersionValid(" "));
Assert.False(ContractValidation.IsDisplayNameValid(string.Empty));
Assert.False(ContractValidation.IsDisplayNameValid(" "));
}
[Fact] [Fact]
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly() public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
{ {
@@ -8,6 +8,7 @@ public sealed class ContractSerializationTests
public static TheoryData<string, Type> GoldenJsonVectors => new() public static TheoryData<string, Type> GoldenJsonVectors => new()
{ {
{ "register-session.json", typeof(RegisterSessionRequest) }, { "register-session.json", typeof(RegisterSessionRequest) },
{ "register-session-response.json", typeof(RegisterSessionResponse) },
{ "browse-sessions.json", typeof(BrowseSessionsResponse) }, { "browse-sessions.json", typeof(BrowseSessionsResponse) },
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) }, { "create-join-response.json", typeof(CreateJoinAttemptResponse) },
{ "api-error.json", typeof(ApiError) }, { "api-error.json", typeof(ApiError) },
@@ -64,5 +64,26 @@ public sealed class OpenApiCompatibilityTests
property.Contains("token", StringComparison.OrdinalIgnoreCase) property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase) || property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase)); || property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
JsonElement publisherBearer = root.GetProperty("components")
.GetProperty("securitySchemes")
.GetProperty("PublisherBearer");
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
(string Path, string Method)[] publisherOperations =
[
("/v1/sessions", "post"),
("/v1/sessions/{listingId}", "put"),
("/v1/sessions/{listingId}", "delete"),
("/v1/sessions/{listingId}/renew", "post"),
];
foreach ((string operationPath, string method) in publisherOperations)
{
JsonElement security = root.GetProperty("paths")
.GetProperty(operationPath)
.GetProperty(method)
.GetProperty("security");
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
}
} }
} }
@@ -0,0 +1,83 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class GamePolicyTests
{
[Fact]
public void RegistryFailsClosedForUnknownAndDisabledScopes()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy("unscouted", "staging", enabled: false),
]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out _));
Assert.False(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("staging"),
out _));
Assert.False(registry.TryGet(
new GameId("unscouted"),
new EnvironmentId("staging"),
out _));
}
[Fact]
public void PerGamePolicyConstrainsProtocolMetadataQuotasAndFeatures()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[ProvisioningTestData.CreatePolicy()]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out GamePolicy? policy));
Assert.NotNull(policy);
Assert.True(policy.AllowsProtocol(7));
Assert.False(policy.AllowsProtocol(8));
Assert.True(policy.AllowsRegion(new RegionId("eu-central")));
Assert.False(policy.AllowsRegion(new RegionId("us-east")));
Assert.True(policy.AllowsVisibility(ListingVisibility.Public));
Assert.True(policy.AllowsPublisherTrust(PublisherTrustMode.PlayerGrant));
Assert.Equal(FallbackPolicyMode.DedicatedEndpointAllowed, policy.FallbackPolicy);
Assert.Equal(10, policy.MaxListingsPerPrincipal);
Assert.Equal(1, policy.MaxAnonymousListingsPerAddress);
Assert.Equal(100, policy.MaxActiveJoinAttempts);
Assert.True(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["unknown"] = "value",
}));
}
[Fact]
public void InvalidOrDuplicatePolicyConfigurationFailsAtStartup()
{
GamePolicyOptions invalid = ProvisioningTestData.CreatePolicy();
invalid.ProtocolVersions = [];
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create([invalid]));
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy(),
]));
}
}
@@ -0,0 +1,225 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
DedicatedPublisherPrincipal dedicated = ProvisioningTestData.CreateDedicatedPublisher();
PlayerHostGrantPrincipal playerGrant = new(
"host-grant-7",
ProvisioningTestData.Now.AddMinutes(5),
dedicated.GameId,
dedicated.EnvironmentId,
dedicated.AllowedRegions);
CredentialValidationResult dedicatedResult = service.Validate(
service.Issue(dedicated, ProvisioningTestData.Now),
ProvisioningTestData.Now);
CredentialValidationResult grantResult = service.Validate(
service.Issue(playerGrant, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<DedicatedPublisherPrincipal>(dedicatedResult.Principal);
Assert.IsType<PlayerHostGrantPrincipal>(grantResult.Principal);
}
[Fact]
public void WrongIssuerAndAudienceAreRejectedAfterSignatureValidation()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService issuer = CreateService(keys);
string token = issuer.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
PrincipalCredentialService wrongIssuer = new(
"other-issuer",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
PrincipalCredentialService wrongAudience = new(
"final-factory-rendezvous",
"other-audience",
TimeSpan.FromSeconds(30),
keys);
Assert.Equal(
CredentialValidationError.IssuerMismatch,
wrongIssuer.Validate(token, ProvisioningTestData.Now).Error);
Assert.Equal(
CredentialValidationError.AudienceMismatch,
wrongAudience.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void ExpiryTamperingAndRevocationAreRejected()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(1)),
ProvisioningTestData.Now);
char replacement = token[^1] == 'A' ? 'B' : 'A';
string tampered = token[..^1] + replacement;
Assert.Equal(
CredentialValidationError.Expired,
service.Validate(token, ProvisioningTestData.Now.AddSeconds(91)).Error);
Assert.Equal(
CredentialValidationError.SignatureInvalid,
service.Validate(tampered, ProvisioningTestData.Now).Error);
Assert.True(keys.Revoke("key-1"));
Assert.Equal(
CredentialValidationError.KeyRevoked,
service.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void KeyRotationHonorsOverlapAndRejectsRetiredKeys()
{
SigningKeyOptions oldKey = ProvisioningTestData.CreateKey(
"old-key",
"old-secret",
ProvisioningTestData.Now.AddHours(-1),
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddMinutes(60));
SigningKeyOptions newKey = ProvisioningTestData.CreateKey(
"new-key",
"new-secret",
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddHours(2),
ProvisioningTestData.Now.AddHours(3));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets(
"old-secret",
"new-secret");
using SigningKeyRing keys = SigningKeyRing.Create([oldKey, newKey], secrets);
PrincipalCredentialService service = CreateService(keys);
string oldToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(50)),
ProvisioningTestData.Now);
Assert.True(service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
Assert.Equal(
CredentialValidationError.KeyRetired,
service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(61)).Error);
string newToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(90)),
ProvisioningTestData.Now.AddMinutes(20));
Assert.True(service.Validate(newToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
}
[Fact]
public void OperatorCredentialNeverBecomesAPublisherPrincipal()
{
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
credentialKinds: [PrincipalCredentialKind.Operator],
gameId: null,
environmentId: null);
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[operatorKey],
secrets);
PrincipalCredentialService service = CreateService(keys);
OperatorPrincipal operatorPrincipal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(5),
new HashSet<OperatorPermission> { OperatorPermission.RotateKeys });
CredentialValidationResult result = service.Validate(
service.Issue(operatorPrincipal, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<OperatorPrincipal>(result.Principal);
Assert.IsNotAssignableFrom<IPublisherPrincipal>(result.Principal);
}
[Fact]
public void ConfiguredRevocationDoesNotRequireRetiredSecretMaterial()
{
SigningKeyOptions revoked = ProvisioningTestData.CreateKey(
"revoked-key",
"removed-secret",
revoked: true);
SigningKeyOptions active = ProvisioningTestData.CreateKey(
"active-key",
"active-secret");
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("active-secret");
using SigningKeyRing keys = SigningKeyRing.Create([revoked, active], secrets);
Assert.Equal(
VerificationKeyLookup.Revoked,
keys.FindVerificationKey("revoked-key", ProvisioningTestData.Now, out _));
Assert.True(keys.TryGetSigningKey(
ProvisioningTestData.Now,
PrincipalCredentialKind.DedicatedPublisher,
"space-game",
"production",
out SigningKey? signingKey));
Assert.Equal("active-key", signingKey?.KeyId);
}
[Fact]
public void SigningKeyAuthorityRejectsCrossGameClaimsEvenWithAValidSignature()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
Assert.Equal(
VerificationKeyLookup.Available,
keys.FindVerificationKey("key-1", ProvisioningTestData.Now, out SigningKey? signingKey));
Assert.NotNull(signingKey);
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
Subject = "malicious-grant-issuer",
Kind = PrincipalCredentialKind.PlayerHostGrant,
GameId = "unscouted",
EnvironmentId = "production",
Regions = ["eu-central"],
IssuedAtUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = ProvisioningTestData.Now.AddMinutes(5).ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
string encodedPayload = Base64Url.Encode(
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"rv1.key-1.{encodedPayload}";
string token = $"{signedContent}.{Base64Url.Encode(signingKey.Sign(signedContent))}";
CredentialValidationResult result = CreateService(keys).Validate(
token,
ProvisioningTestData.Now);
Assert.Equal(CredentialValidationError.KeyScopeMismatch, result.Error);
Assert.Null(result.Principal);
}
private static PrincipalCredentialService CreateService(SigningKeyRing keys) => new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
}
@@ -0,0 +1,97 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class ProvisioningSecurityTests
{
[Fact]
public void StartupFailsClearlyWhenKeyMaterialIsAbsent()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions(
ProvisioningTestData.CreateKey(secretReference: "missing-production-secret"));
using DictionarySecretProvider empty = ProvisioningTestData.CreateSecrets();
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, empty, ProvisioningTestData.Now));
Assert.Contains("key-1", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("missing-production-secret", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void StartupRequiresAnActivePublisherKeyForEveryEnabledPolicy()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
options.Games.Add(ProvisioningTestData.CreatePolicy("unscouted", "production"));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, secrets, ProvisioningTestData.Now));
Assert.Contains("unscouted/production", exception.Message, StringComparison.Ordinal);
Assert.Contains("key", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SecretMaterialCredentialsAndKeysAreRedactedFromDiagnostics()
{
byte[] knownSecret = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray();
string encodedSecret = Convert.ToBase64String(knownSecret);
using SecretMaterial material = new(knownSecret);
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
{
["secret-1"] = knownSecret,
});
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
CredentialValidationResult result = service.Validate(token, ProvisioningTestData.Now);
string diagnostics = string.Join(
'|',
material,
secrets,
keys,
service,
result);
Assert.DoesNotContain(encodedSecret, diagnostics, StringComparison.Ordinal);
Assert.DoesNotContain(token, diagnostics, StringComparison.Ordinal);
Assert.Contains("redacted", diagnostics, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PublicClientAndContractSurfacesContainNoProvisioningSecrets()
{
Type[] publicTypes = typeof(GameId).Assembly.GetExportedTypes()
.Concat(Assembly.Load("FinalFactory.Rendezvous.Client").GetExportedTypes())
.ToArray();
string[] forbiddenTerms =
[
"GameSecret",
"SigningKey",
"KeyMaterial",
"PublisherCredential",
"SecretProvider",
];
foreach (Type type in publicTypes)
{
IEnumerable<string> names = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
.Select(static member => member.Name)
.Append(type.Name);
Assert.DoesNotContain(names, name => forbiddenTerms.Any(term =>
name.Contains(term, StringComparison.OrdinalIgnoreCase)));
}
}
}
@@ -0,0 +1,98 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
internal static class ProvisioningTestData
{
public static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
public static GamePolicyOptions CreatePolicy(
string gameId = "space-game",
string environmentId = "production",
bool enabled = true) => new()
{
GameId = gameId,
EnvironmentId = environmentId,
Enabled = enabled,
ProtocolVersions = [7],
Regions = ["eu-central"],
VisibilityModes = [ListingVisibility.Public, ListingVisibility.Unlisted],
PublisherTrustModes =
[
PublisherTrustMode.ManagedDedicated,
PublisherTrustMode.PlayerGrant,
PublisherTrustMode.AnonymousUnlisted,
],
MetadataValueMaxBytes = new Dictionary<string, int>(StringComparer.Ordinal)
{
["map"] = 32,
["mode"] = 16,
},
RequiredMetadataKeys = ["mode"],
MetadataMaxBytes = 256,
MetadataMaxKeys = 2,
MaxListingsPerPrincipal = 10,
MaxAnonymousListingsPerAddress = 1,
MaxActiveJoinAttempts = 100,
FallbackPolicy = FallbackPolicyMode.DedicatedEndpointAllowed,
};
public static SigningKeyOptions CreateKey(
string keyId = "key-1",
string secretReference = "secret-1",
DateTimeOffset? notBefore = null,
DateTimeOffset? signUntil = null,
DateTimeOffset? verifyUntil = null,
bool revoked = false,
IEnumerable<PrincipalCredentialKind>? credentialKinds = null,
string? gameId = "space-game",
string? environmentId = "production") => new()
{
KeyId = keyId,
SecretReference = secretReference,
CredentialKinds = credentialKinds?.ToList()
?? [
PrincipalCredentialKind.DedicatedPublisher,
PrincipalCredentialKind.PlayerHostGrant,
],
GameId = gameId,
EnvironmentId = environmentId,
NotBefore = notBefore ?? Now.AddHours(-1),
SignUntil = signUntil ?? Now.AddHours(1),
VerifyUntil = verifyUntil ?? Now.AddHours(2),
Revoked = revoked,
};
public static DictionarySecretProvider CreateSecrets(params string[] references)
{
Dictionary<string, byte[]> secrets = new(StringComparer.Ordinal);
for (int index = 0; index < references.Length; index++)
{
secrets.Add(
references[index],
Enumerable.Range(1 + index, 32).Select(static value => (byte)value).ToArray());
}
return new DictionarySecretProvider(secrets);
}
public static DedicatedPublisherPrincipal CreateDedicatedPublisher(
DateTimeOffset? expiresAt = null,
string gameId = "space-game",
string environmentId = "production") => new(
"workload-42",
expiresAt ?? Now.AddMinutes(10),
new GameId(gameId),
new EnvironmentId(environmentId),
new HashSet<RegionId> { new("eu-central") });
public static ProvisioningOptions CreateOptions(SigningKeyOptions? key = null) => new()
{
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
ClockSkewSeconds = 30,
SigningKeys = [key ?? CreateKey()],
Games = [CreatePolicy()],
};
}
@@ -0,0 +1,124 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PublisherAuthorizationTests
{
private static readonly IReadOnlyDictionary<string, string> ValidMetadata =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
};
[Fact]
public void AuthoritativeScopeComesFromThePublisherPrincipal()
{
PublisherAuthorizationService service = CreateService();
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
PublisherAuthorizationResult result = service.Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.True(result.IsAllowed);
Assert.NotNull(result.Context);
Assert.Equal(principal.GameId, result.Context.GameId);
Assert.Equal(principal.EnvironmentId, result.Context.EnvironmentId);
}
[Theory]
[InlineData("unscouted", "production")]
[InlineData("space-game", "staging")]
public void CrossGameAndEnvironmentScopeEscalationIsDenied(
string requestedGame,
string requestedEnvironment)
{
PublisherAuthorizationResult result = CreateService().Authorize(
ProvisioningTestData.CreateDedicatedPublisher(),
new GameId(requestedGame),
new EnvironmentId(requestedEnvironment),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.False(result.IsAllowed);
Assert.Equal(PublisherAuthorizationError.ScopeMismatch, result.Error);
Assert.Null(result.Context);
}
[Fact]
public void OperatorCannotBeUsedAsAGamePublisher()
{
OperatorPrincipal principal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(10),
new HashSet<OperatorPermission> { OperatorPermission.ReadPolicy });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.NotPublisher, result.Error);
}
[Fact]
public void AnonymousPublisherCanNeverEscalateToPublicVisibility()
{
AnonymousUnlistedPrincipal principal = new(
"anonymous-source-1",
ProvisioningTestData.Now.AddMinutes(2),
new GameId("space-game"),
new EnvironmentId("production"),
new HashSet<RegionId> { new("eu-central") });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.AnonymousMustBeUnlisted, result.Error);
}
[Fact]
public void ExpiredPrincipalIsRecheckedAtAuthorizationTime()
{
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddSeconds(-1));
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.PrincipalExpired, result.Error);
}
private static PublisherAuthorizationService CreateService() => new(
GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]));
}
@@ -1,5 +1,10 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.Sessions;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -7,6 +12,39 @@ namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class UdpMediatorServiceTests public sealed class UdpMediatorServiceTests
{ {
[Fact]
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
NullLogger<UdpMediatorService>.Instance,
fixture.Store,
fixture.Capabilities);
PresenceDatagram presence = new()
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = registration.HostPresenceHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.50",
LocalPort = 40_000,
Capability = registration.HostPresenceCapability,
};
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
Assert.Equal(
UdpPresenceProcessingResult.HostPresenceRejected,
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
Assert.Empty(fixture.Browse());
presence.Capability = registration.HostPresenceCapability;
Assert.Equal(
UdpPresenceProcessingResult.HostPresenceAccepted,
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
}
[Fact] [Fact]
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly() public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
{ {
@@ -16,9 +54,14 @@ public sealed class UdpMediatorServiceTests
ListenAddress = IPAddress.Loopback.ToString(), ListenAddress = IPAddress.Loopback.ToString(),
Port = 0, Port = 0,
}; };
ManualRendezvousClock clock = new();
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
using EphemeralCapabilityIssuer capabilities = new();
using UdpMediatorService service = new( using UdpMediatorService service = new(
Options.Create(options), Options.Create(options),
NullLogger<UdpMediatorService>.Instance); NullLogger<UdpMediatorService>.Instance,
store,
capabilities);
await service.StartAsync(timeout.Token); await service.StartAsync(timeout.Token);
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
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.Sessions;
public sealed class SessionHttpEndpointTests
{
[Fact]
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
{
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 publisherCredential = 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.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>();
await using 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);
using HttpClient client = new() { BaseAddress = new Uri(address) };
RegisterSessionRequest registration = new()
{
IdempotencyKey = "http-register-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.4.2",
DisplayName = "HTTP host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
},
};
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
"/v1/sessions",
registration,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
ContractJson.Options);
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
publisherCredential);
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
HttpResponseMessage invalid = await client.PostAsync(
"/v1/sessions",
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
HttpResponseMessage created = await client.PostAsJsonAsync(
"/v1/sessions",
registration,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
ContractJson.Options);
Assert.NotNull(session);
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
Assert.True(capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null));
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7&regionId=eu-central&pageSize=10&excludeFull=true",
ContractJson.Options);
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
ContractJson.Options);
Assert.Equal(session.ListingId, direct!.Session.ListingId);
HttpResponseMessage renewed = await client.PostAsJsonAsync(
$"/v1/sessions/{session.ListingId}/renew",
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
ContractJson.Options);
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
HttpResponseMessage updated = await client.PutAsJsonAsync(
$"/v1/sessions/{session.ListingId}",
new UpdateSessionRequest
{
LeaseToken = session.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "HTTP host updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
},
ContractJson.Options);
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
using HttpRequestMessage deleteRequest = new(
HttpMethod.Delete,
$"/v1/sessions/{session.ListingId}")
{
Content = JsonContent.Create(
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
options: ContractJson.Options),
};
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
await app.StopAsync();
}
}
@@ -0,0 +1,294 @@
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.Tests.Sessions;
public sealed class SessionLeaseServiceTests
{
[Fact]
public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse response = fixture.Register();
Assert.Equal(30, response.LeaseRenewAfterSeconds);
Assert.Equal(10, response.HostPresenceRefreshAfterSeconds);
Assert.Equal(43, response.LeaseToken.Length);
Assert.Equal(43, response.HostPresenceCapability.Length);
Assert.Empty(fixture.Browse());
string json = JsonSerializer.Serialize(response, ContractJson.Options);
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext()
{
using SessionLeaseFixture fixture = new();
RegisterSessionRequest request = fixture.Request("same-key");
RegisterSessionResponse first = fixture.Register(request);
RegisterSessionRequest reordered = fixture.Request("same-key");
reordered.Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["map"] = "europa",
["mode"] = "co-op",
};
RegisterSessionResponse duplicate = fixture.Register(reordered);
RegisterSessionRequest changedRequest = fixture.Request("same-key");
changedRequest.DisplayName = "Changed";
SessionServiceResult<RegisterSessionResponse> changed = fixture.Service.Register(
fixture.Principal,
changedRequest);
Assert.Equal(first.ListingId, duplicate.ListingId);
Assert.Equal(first.LeaseId, duplicate.LeaseId);
Assert.Equal(first.LeaseToken, duplicate.LeaseToken);
Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle);
Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability);
Assert.Equal(RendezvousErrorCode.Conflict, changed.Error);
}
[Fact]
public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities()
{
EphemeralStoreOptions options = new()
{
LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6),
};
using SessionLeaseFixture fixture = new(options);
RegisterSessionRequest request = fixture.Request("reused-after-expiry");
RegisterSessionResponse first = fixture.Register(request);
fixture.Clock.Advance(options.IdempotencyLifetime);
RegisterSessionResponse second = fixture.Register(request);
Assert.NotEqual(first.ListingId, second.ListingId);
Assert.NotEqual(first.LeaseId, second.LeaseId);
Assert.NotEqual(first.LeaseToken, second.LeaseToken);
Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability);
}
[Fact]
public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
Assert.Empty(fixture.Browse());
Assert.True(fixture.BindPresence(registration).Succeeded);
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime);
Assert.Empty(fixture.Browse());
Assert.True(fixture.BindPresence(registration).Succeeded);
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
}
[Fact]
public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
SessionServiceResult<RenewLeaseResponse> renewed = fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
SessionServiceResult<bool> updated = fixture.Service.Update(
fixture.Principal,
registration.ListingId,
new()
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Europa Updated",
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
});
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
Assert.True(renewed.Succeeded);
Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt);
Assert.True(updated.Succeeded);
Assert.Equal(registration.ListingId, stored.Definition.ListingId);
Assert.Equal(fixture.Scope, stored.Definition.Scope);
Assert.Equal(8, stored.Definition.CurrentPlayers);
Assert.Equal(8, stored.Definition.MaximumPlayers);
Assert.True(fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.True(fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
}
[Fact]
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2");
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
other,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update(
other,
registration.ListingId,
new()
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Hijacked",
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 },
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
}).Error);
Assert.True(fixture.Service.Delete(
other,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded);
}
[Fact]
public async Task ConcurrentRenewDeleteCannotResurrectListing()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
using ManualResetEventSlim start = new(false);
Task<SessionServiceResult<RenewLeaseResponse>> renew = Task.Run(() =>
{
start.Wait();
return fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
});
Task<SessionServiceResult<bool>> delete = Task.Run(() =>
{
start.Wait();
return fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
});
start.Set();
await Task.WhenAll(renew, delete);
SessionServiceResult<RenewLeaseResponse> renewResult = await renew;
SessionServiceResult<bool> deleteResult = await delete;
Assert.True(deleteResult.Succeeded);
Assert.Contains(renewResult.Error, new[]
{
RendezvousErrorCode.None,
RendezvousErrorCode.NotFound,
RendezvousErrorCode.Conflict,
});
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
}
[Fact]
public void AbandonedRegistrationExpiresAndFreesBoundedCapacity()
{
EphemeralStoreOptions options = new()
{
MaxListings = 1,
LeaseLifetime = TimeSpan.FromSeconds(5),
};
using SessionLeaseFixture fixture = new(options);
fixture.Register(fixture.Request("first"));
Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register(
fixture.Principal,
fixture.Request("second")).Error);
fixture.Clock.Advance(options.LeaseLifetime);
Assert.True(fixture.Service.Register(
fixture.Principal,
fixture.Request("second")).Succeeded);
}
[Fact]
public void LeaseExpiryRemovesMutationAndPresencePaths()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.BindPresence(registration);
fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime);
Assert.Empty(fixture.Browse());
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code);
}
[Fact]
public void LossOfAtomicStateFailsLeaseMutationClosed()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.Store.MarkUnavailable();
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
}
[Fact]
public void InvalidPolicyBoundInputsReturnStableTypedErrors()
{
using SessionLeaseFixture fixture = new();
RegisterSessionRequest capacity = fixture.Request("bad-capacity");
capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 };
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
capacity).Error);
RegisterSessionRequest protocol = fixture.Request("bad-protocol");
protocol.ProtocolVersion = 8;
Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register(
fixture.Principal,
protocol).Error);
RegisterSessionRequest region = fixture.Request("bad-region");
region.RegionId = new("us-east");
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
region).Error);
RegisterSessionRequest visibility = fixture.Request("bad-visibility");
visibility.Visibility = (ListingVisibility)99;
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
visibility).Error);
RegisterSessionRequest metadata = fixture.Request("bad-metadata");
metadata.Metadata = new Dictionary<string, string> { ["unknown"] = "value" };
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
metadata).Error);
RegisterSessionRequest build = fixture.Request("bad-build");
build.BuildVersion = " ";
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
build).Error);
}
}
@@ -0,0 +1,93 @@
using FinalFactory.Rendezvous.Contracts;
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;
namespace FinalFactory.Rendezvous.Tests.Sessions;
internal sealed class SessionLeaseFixture : IDisposable
{
private int _sequence;
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
{
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
Clock = new();
Store = new(StoreOptions, Clock, Clock);
Capabilities = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
Service = new(
new PublisherAuthorizationService(policies),
Store,
Capabilities,
SessionLeaseTiming.From(StoreOptions),
Clock);
Principal = Publisher("publisher-1");
}
public EphemeralStoreOptions StoreOptions { get; }
public ManualRendezvousClock Clock { get; }
public InMemoryEphemeralRendezvousStore Store { get; }
public EphemeralCapabilityIssuer Capabilities { get; }
public SessionLeaseService Service { get; }
public DedicatedPublisherPrincipal Principal { get; }
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
public DedicatedPublisherPrincipal Publisher(string subject) => new(
subject,
Clock.UtcNow.AddMinutes(10),
Scope.GameId,
Scope.EnvironmentId,
new HashSet<RegionId> { new("eu-central") });
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
{
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
GameId = Scope.GameId,
EnvironmentId = Scope.EnvironmentId,
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.4.2",
DisplayName = "Europa Relay",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
};
public RegisterSessionResponse Register(
RegisterSessionRequest? request = null,
DedicatedPublisherPrincipal? principal = null)
{
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
principal ?? Principal,
request ?? Request());
Assert.True(result.Succeeded);
Assert.NotNull(result.Value);
return result.Value;
}
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
{
Assert.True(Capabilities.TryFingerprint(
registration.HostPresenceCapability,
out SecretFingerprint fingerprint));
return Store.BindHostPresence(new(
registration.HostPresenceHandle,
fingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
}
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
Scope,
7,
new RegionId("eu-central"))).Value!;
public void Dispose() => Capabilities.Dispose();
}
@@ -0,0 +1,109 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State;
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
{
public ManualRendezvousClock(DateTimeOffset? utcNow = null) =>
UtcNow = utcNow ?? new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
public DateTimeOffset UtcNow { get; private set; }
public TimeSpan Elapsed { get; private set; }
public void Advance(TimeSpan duration)
{
Elapsed += duration;
UtcNow += duration;
}
public void MoveWall(TimeSpan duration) => UtcNow += duration;
}
internal sealed class EphemeralStateFixture
{
private int _sequence;
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
{
Clock = new();
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
}
public ManualRendezvousClock Clock { get; }
public InMemoryEphemeralRendezvousStore Store { get; }
public TenantScope Scope { get; } = new(new GameId("space-game"), new EnvironmentId("test"));
public CreateListingCommand ListingCommand(
string owner = "publisher-1",
string? idempotencyKey = null,
string? requestFingerprint = null)
{
int sequence = Interlocked.Increment(ref _sequence);
return new(
idempotencyKey ?? $"register-{sequence}",
requestFingerprint ?? $"request-{sequence}",
new ListingDefinition
{
ListingId = NewListingId(),
LeaseId = NewLeaseId(),
Scope = Scope,
OwnerSubject = owner,
RegionId = new RegionId("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.2.3",
DisplayName = "Test host",
Visibility = ListingVisibility.Public,
TrustMode = PublisherTrustMode.ManagedDedicated,
CurrentPlayers = 1,
MaximumPlayers = 8,
Metadata = new Dictionary<string, string>(StringComparer.Ordinal) { ["mode"] = "coop" },
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
HostPresenceHandle = NewHandle(),
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
});
}
public StoredListing CreateVisibleListing(out CreateListingCommand command)
{
command = ListingCommand();
StoreResult<StoredListing> created = Store.CreateListing(command);
Assert.True(created.Succeeded);
StoreResult<StoredListing> bound = Store.BindHostPresence(new(
command.Listing.HostPresenceHandle,
command.Listing.HostPresenceFingerprint,
PublicEndpoint(40_000),
LocalEndpoint(40_000)));
Assert.True(bound.Succeeded);
return bound.Value!;
}
public CreateJoinAttemptCommand AttemptCommand(StoredListing listing, string owner = "client-1")
{
int sequence = Interlocked.Increment(ref _sequence);
return new()
{
IdempotencyOwner = owner,
IdempotencyKey = $"join-{sequence}",
RequestFingerprint = $"join-request-{sequence}",
ClientSubject = owner,
AttemptId = NewAttemptId(),
MediationHandle = NewHandle(),
Scope = listing.Definition.Scope,
ListingId = listing.Definition.ListingId,
ProtocolVersion = listing.Definition.ProtocolVersion,
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
};
}
public static SecretFingerprint Fingerprint(string value) => new(value);
public static ObservedEndpoint PublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "203.0.113.10", port);
public static ObservedEndpoint OtherPublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "198.51.100.20", port);
public static ObservedEndpoint LocalEndpoint(int port) => new(AddressFamilyKind.Ipv4, "192.168.1.20", port);
public static SessionListingId NewListingId() => new(Guid.NewGuid());
public static LeaseId NewLeaseId() => new(Guid.NewGuid());
public static JoinAttemptId NewAttemptId() => new(Guid.NewGuid());
public static MediationHandle NewHandle() => new(Guid.NewGuid());
}
@@ -0,0 +1,461 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State;
public sealed class InMemoryEphemeralRendezvousStoreTests
{
[Fact]
public void IdempotencyRetentionMustCoverResourceLifetimes()
{
ManualRendezvousClock clock = new();
Assert.Throws<ArgumentOutOfRangeException>(() => new InMemoryEphemeralRendezvousStore(
new EphemeralStoreOptions { IdempotencyLifetime = TimeSpan.FromSeconds(5) },
clock,
clock));
}
[Fact]
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
{
EphemeralStateFixture fixture = new();
CreateListingCommand command = fixture.ListingCommand();
StoreResult<StoredListing> first = fixture.Store.CreateListing(command);
StoreResult<StoredListing> duplicate = fixture.Store.CreateListing(command);
StoreResult<StoredListing> changed = fixture.Store.CreateListing(command with { RequestFingerprint = "different" });
Assert.True(first.Succeeded);
Assert.True(duplicate.Succeeded);
Assert.True(duplicate.IsIdempotentReplay);
Assert.Equal(first.Value!.Definition.ListingId, duplicate.Value!.Definition.ListingId);
Assert.Equal(StoreResultCode.Conflict, changed.Code);
}
[Fact]
public void LeaseAndPresenceExpiryUseMonotonicTime()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, true).Code);
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
fixture.Clock.Advance(TimeSpan.FromSeconds(40));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
}
[Fact]
public void WallClockMovementDoesNotExpireOrExtendLease()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
fixture.Clock.MoveWall(TimeSpan.FromDays(30));
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
StoreResult<StoredListing> renewed = fixture.Store.RenewLease(new(
listing.Definition.ListingId,
listing.Definition.LeaseId,
listing.Definition.LeaseFingerprint,
listing.Definition.OwnerSubject,
listing.Version));
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
fixture.Clock.Advance(TimeSpan.FromSeconds(59));
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
}
[Fact]
public async Task RenewDeleteRaceIsAtomicAndDeleteAlwaysWinsEventually()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
using ManualResetEventSlim start = new(false);
Task<StoreResult<StoredListing>> renew = Task.Run(() =>
{
start.Wait();
return fixture.Store.RenewLease(new(
listing.Definition.ListingId,
listing.Definition.LeaseId,
listing.Definition.LeaseFingerprint,
listing.Definition.OwnerSubject,
listing.Version));
});
Task<StoreResult<bool>> delete = Task.Run(() =>
{
start.Wait();
return fixture.Store.DeleteListing(new(
command.Listing.ListingId,
command.Listing.LeaseId,
command.Listing.LeaseFingerprint,
command.Listing.OwnerSubject));
});
start.Set();
await Task.WhenAll(renew, delete);
StoreResult<StoredListing> renewResult = await renew;
StoreResult<bool> deleteResult = await delete;
Assert.True(deleteResult.Succeeded);
Assert.Contains(renewResult.Code, new[] { StoreResultCode.Success, StoreResultCode.NotFound });
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
}
[Fact]
public void CompareAndSwapPreventsStaleRenewal()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
RenewLeaseCommand command = new(
listing.Definition.ListingId,
listing.Definition.LeaseId,
listing.Definition.LeaseFingerprint,
listing.Definition.OwnerSubject,
listing.Version);
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
StoreResult<StoredListing> stale = fixture.Store.RenewLease(command);
Assert.Equal(2, first.Value!.Version);
Assert.Equal(StoreResultCode.Conflict, stale.Code);
Assert.Equal(2, stale.Value!.Version);
}
[Fact]
public void JoinRequiresExactScopeProtocolAndFreshHostPresence()
{
EphemeralStateFixture fixture = new();
CreateListingCommand listingCommand = fixture.ListingCommand();
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt).Code);
fixture.Store.BindHostPresence(new(
listingCommand.Listing.HostPresenceHandle,
listingCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000),
null));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
{
Scope = new(new("other-game"), new("test")),
}).Code);
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
}
[Fact]
public void DuplicateJoinIsIdempotentAndDoesNotAllocateTwice()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
StoreResult<StoredJoinAttempt> first = fixture.Store.CreateJoinAttempt(command);
StoreResult<StoredJoinAttempt> duplicate = fixture.Store.CreateJoinAttempt(command);
Assert.True(first.Succeeded);
Assert.True(duplicate.Succeeded);
Assert.True(duplicate.IsIdempotentReplay);
Assert.Equal(first.Value!.AttemptId, duplicate.Value!.AttemptId);
}
[Fact]
public void BrowseReturnsOnlyFreshPublicCompatibleListingsInStableOrder()
{
EphemeralStateFixture fixture = new();
StoredListing visible = fixture.CreateVisibleListing(out _);
CreateListingCommand staleCommand = fixture.ListingCommand();
fixture.Store.CreateListing(staleCommand);
CreateListingCommand unlistedCommand = fixture.ListingCommand();
unlistedCommand = unlistedCommand with
{
Listing = unlistedCommand.Listing with { Visibility = ListingVisibility.Unlisted },
};
fixture.Store.CreateListing(unlistedCommand);
fixture.Store.BindHostPresence(new(
unlistedCommand.Listing.HostPresenceHandle,
unlistedCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_099),
null));
StoreResult<IReadOnlyList<StoredListing>> result = fixture.Store.BrowseVisibleListings(new(
fixture.Scope,
visible.Definition.ProtocolVersion,
visible.Definition.RegionId));
Assert.True(result.Succeeded);
Assert.Collection(result.Value!, item => Assert.Equal(visible.Definition.ListingId, item.Definition.ListingId));
}
[Fact]
public async Task ConcurrentEndpointBindingAcceptsOneCompleteEndpointOnly()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
fixture.Store.CreateJoinAttempt(command);
BindAttemptEndpointCommand first = new(
command.MediationHandle,
AttemptPeerRole.Client,
command.ClientCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(40_001),
EphemeralStateFixture.LocalEndpoint(40_001));
BindAttemptEndpointCommand second = first with
{
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(50_001),
LocalEndpoint = null,
};
using ManualResetEventSlim start = new(false);
Task<StoreResult<StoredJoinAttempt>> left = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(first); });
Task<StoreResult<StoredJoinAttempt>> right = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(second); });
start.Set();
await Task.WhenAll(left, right);
StoreResult<StoredJoinAttempt> leftResult = await left;
StoreResult<StoredJoinAttempt> rightResult = await right;
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Succeeded));
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Code == StoreResultCode.ReplayRejected));
}
[Fact]
public void AttemptCapabilitiesAndIntroductionAreOneTime()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
fixture.Store.CreateJoinAttempt(command);
BindAttemptEndpointCommand host = new(
command.MediationHandle,
AttemptPeerRole.Host,
command.HostCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(40_010),
null);
BindAttemptEndpointCommand client = new(
command.MediationHandle,
AttemptPeerRole.Client,
command.ClientCapabilityFingerprint,
EphemeralStateFixture.OtherPublicEndpoint(40_020),
null);
Assert.True(fixture.Store.BindAttemptEndpoint(host).Succeeded);
Assert.True(fixture.Store.BindAttemptEndpoint(client).Succeeded);
Assert.True(fixture.Store.BindAttemptEndpoint(client).IsIdempotentReplay);
Assert.True(fixture.Store.ConsumeIntroduction(command.MediationHandle).Succeeded);
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.BindAttemptEndpoint(client with
{
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(40_021),
}).Code);
}
[Fact]
public void ExpiredAttemptCannotBeObservedBoundOrConsumed()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
fixture.Store.CreateJoinAttempt(command);
fixture.Clock.Advance(TimeSpan.FromSeconds(30));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
command.MediationHandle,
AttemptPeerRole.Client,
command.ClientCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(40_050),
null)).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
}
[Fact]
public void GenericReplayConsumptionIsBoundedAndExpires()
{
EphemeralStoreOptions options = new() { MaxReplayEntries = 1 };
EphemeralStateFixture fixture = new(options);
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "one")).Succeeded);
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeReplay(new("ticket", "one")).Code);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.ConsumeReplay(new("ticket", "two")).Code);
fixture.Clock.Advance(options.ReplayLifetime);
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "two")).Succeeded);
}
[Fact]
public void PresenceAttemptAndRevocationPoolsShedWithoutPartialMutation()
{
EphemeralStoreOptions options = new()
{
MaxPresenceBindings = 1,
MaxJoinAttempts = 1,
MaxRevocations = 1,
};
EphemeralStateFixture fixture = new(options);
StoredListing first = fixture.CreateVisibleListing(out CreateListingCommand firstCommand);
CreateListingCommand secondCommand = fixture.ListingCommand(owner: "publisher-2");
fixture.Store.CreateListing(secondCommand);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.BindHostPresence(new(
secondCommand.Listing.HostPresenceHandle,
secondCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.OtherPublicEndpoint(42_000),
null)).Code);
Assert.True(fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first)).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first, "client-2")).Code);
Assert.True(fixture.Store.RevokePrincipal("unrelated", TimeSpan.FromMinutes(1)).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.RevokePrincipal(firstCommand.Listing.OwnerSubject, TimeSpan.FromMinutes(1)).Code);
Assert.True(fixture.Store.GetListing(first.Definition.ListingId, true).Succeeded);
Assert.True(fixture.Store.GetListing(secondCommand.Listing.ListingId, false).Succeeded);
}
[Fact]
public void ExhaustionShedsNewListingWithoutMutatingExistingState()
{
EphemeralStoreOptions options = new() { MaxListings = 1 };
EphemeralStateFixture fixture = new(options);
CreateListingCommand first = fixture.ListingCommand();
CreateListingCommand second = fixture.ListingCommand();
Assert.True(fixture.Store.CreateListing(first).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
}
[Fact]
public void IdempotencyPoolExhaustionDoesNotCreateUntrackedResource()
{
EphemeralStoreOptions options = new() { MaxListings = 2, MaxIdempotencyEntries = 1 };
EphemeralStateFixture fixture = new(options);
CreateListingCommand first = fixture.ListingCommand();
CreateListingCommand second = fixture.ListingCommand();
Assert.True(fixture.Store.CreateListing(first).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
}
[Fact]
public void PolicyQuotasAreCheckedInsideAtomicCreation()
{
EphemeralStateFixture fixture = new();
CreateListingCommand first = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
CreateListingCommand second = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
Assert.True(fixture.Store.CreateListing(first).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
fixture.Store.BindHostPresence(new(
first.Listing.HostPresenceHandle,
first.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(42_100),
null));
StoredListing listing = fixture.Store.GetListing(first.Listing.ListingId, true).Value!;
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing) with { ScopeAttemptLimit = 1 };
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(
fixture.AttemptCommand(listing, "client-quota-2") with { ScopeAttemptLimit = 1 }).Code);
}
[Fact]
public void InvalidDefaultSecurityValuesCannotEnterStore()
{
EphemeralStateFixture fixture = new();
CreateListingCommand command = fixture.ListingCommand();
Assert.Throws<ArgumentException>(() => fixture.Store.CreateListing(command with
{
Listing = command.Listing with { LeaseFingerprint = default },
}));
}
[Fact]
public void RevocationRemovesEveryPathAndBlocksNewWorkAtomically()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing, command.Listing.OwnerSubject);
fixture.Store.CreateJoinAttempt(attempt);
StoreResult<int> revoked = fixture.Store.RevokePrincipal(command.Listing.OwnerSubject, TimeSpan.FromMinutes(1));
Assert.True(revoked.Succeeded);
Assert.Equal(2, revoked.Value);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Client,
attempt.ClientCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(40_030),
null)).Code);
Assert.Equal(StoreResultCode.Revoked, fixture.Store.CreateListing(fixture.ListingCommand(owner: command.Listing.OwnerSubject)).Code);
}
[Fact]
public void RestartHasNewGenerationAndNoEphemeralState()
{
EphemeralStateFixture before = new();
StoredListing listing = before.CreateVisibleListing(out _);
EphemeralStateFixture after = new();
Assert.NotEqual(before.Store.InstanceId, after.Store.InstanceId);
Assert.Equal(StoreResultCode.NotFound, after.Store.GetListing(listing.Definition.ListingId, false).Code);
}
[Fact]
public void DrainRejectsNewWorkAllowsInflightCompletionThenClearsState()
{
EphemeralStoreOptions options = new() { GracefulDrainLifetime = TimeSpan.FromSeconds(5) };
EphemeralStateFixture fixture = new(options);
StoredListing listing = fixture.CreateVisibleListing(out _);
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
fixture.Store.CreateJoinAttempt(attempt);
fixture.Store.BeginDrain();
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateListing(fixture.ListingCommand()).Code);
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
Assert.True(fixture.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Client,
attempt.ClientCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(41_000),
null)).Succeeded);
fixture.Clock.Advance(options.GracefulDrainLifetime);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Host,
attempt.HostCapabilityFingerprint,
EphemeralStateFixture.PublicEndpoint(41_001),
null)).Code);
}
[Fact]
public void PrecancelledOperationHasNoPartialEffect()
{
EphemeralStateFixture fixture = new();
CreateListingCommand command = fixture.ListingCommand();
using CancellationTokenSource cancellation = new();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() => fixture.Store.CreateListing(command, cancellation.Token));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
}
[Fact]
public void UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
fixture.Store.MarkUnavailable();
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
}
}
@@ -18,6 +18,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.String Cursor {get;set;} PROP System.String Cursor {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP System.Boolean ExcludeFull {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.Int32 PageSize {get;set;} PROP System.Int32 PageSize {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;} PROP System.UInt32 ProtocolVersion {get;set;}
@@ -217,7 +218,9 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
PROP System.DateTimeOffset ExpiresAt {get;set;} PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPresenceCapability {get;set;} PROP System.String HostPresenceCapability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;} PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;set;}
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;} PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
PROP System.Int32 LeaseRenewAfterSeconds {get;set;}
PROP System.String LeaseToken {get;set;} PROP System.String LeaseToken {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
@@ -250,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
CTOR () CTOR ()
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;} PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.Int32 RenewAfterSeconds {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
CTOR () CTOR ()
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
@@ -0,0 +1 @@
{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","leaseId":"11112233-4455-6677-8899-aabbccddeeff","leaseToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","hostPresenceHandle":"22222233-4455-6677-8899-aabbccddeeff","hostPresenceCapability":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","expiresAt":"2026-07-16T12:01:00+00:00","leaseRenewAfterSeconds":30,"hostPresenceRefreshAfterSeconds":10}