From a578b752af8e12369b0f7c6418c83662712c2711 Mon Sep 17 00:00:00 2001 From: Zulu Date: Sun, 6 Sep 2026 14:34:59 +0100 Subject: [PATCH] Explain NXS in plain English and add writing guidance --- README.md | 17 +- docs/external-signalling/README.md | 589 ++++++++++++++++++----------- docs/technical-writing.md | 41 ++ 3 files changed, 429 insertions(+), 218 deletions(-) create mode 100644 docs/technical-writing.md diff --git a/README.md b/README.md index d9e7741..3563004 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,18 @@ repositories { ## NetherNet and external signalling -`transport-nethernet` provides the attributed NetherNet transport and local HTTP -signalling integration. `external-signalling` implements the open -[NetherNet External Signalling v1 contract](docs/external-signalling/README.md), -including registration, background lifecycle and stateless admission. -See [contribution provenance and intended submission slices](docs/contribution-provenance.md). +`transport-nethernet` provides NetherNet transport and local HTTP signalling. +`external-signalling` lets a host register with its chosen provider and check +client connection tokens locally. Read the +[NXS v1 specification](docs/external-signalling/README.md) and the +[contribution history and proposed upstream PRs](docs/contribution-provenance.md). Build the pinned native development chain with `bash scripts/bootstrap-native-admission.sh`, then run `./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest`. An existing local Maven repository can be selected with `-PnativeMavenRepository=/absolute/path/to/maven`. The development native artifacts -currently target Linux x86_64 and system OpenSSL; cross-platform release packaging -remains a separate release gate. +currently target Linux x86_64 and system OpenSSL. Other platforms need their own +release builds and tests. + +When editing documentation or PR descriptions, follow the +[technical-writing checklist](docs/technical-writing.md). diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 47cad46..1c7cd25 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -1,17 +1,60 @@ # NetherNet External Signalling v1 -Status: experimental open specification. Identifier: `urn:nethernet:external-signalling:v1`. -Licensed under this repository's Apache-2.0 license. The schema, canonical fixtures, -and this document form one versioned contract. Product account systems, credential -issuance, billing, Microsoft login, DNS management, and host-selection policy are -outside the contract. - -NXS connects a running NetherNet host to an operator-selected signalling provider. -The provider can issue an answer using previously published host information. -The host validates the connecting client's first STUN packet without a per-client -request to the provider. A conforming implementation MUST NOT require any push, -poll, shared lookup, offer fetch, or pre-staged client state to admit that client. -Transport and game outcomes are asynchronous observations, never admission prerequisites. +NetherNet External Signalling (NXS) lets a NetherNet server use a signalling +provider chosen by its operator. The server registers with the provider and +publishes the information clients need to connect. Each client then brings a +short-lived token that the server can check locally. + +For example, a server can publish its address and certificate fingerprint when +it starts. Later, the provider gives a client those details and a token. The +server checks the token in the client's first packet. It does not need to ask +the provider whether to accept that connection. + +This is an experimental open specification, identified by +`urn:nethernet:external-signalling:v1`. This document, the +[schema](nxs-v1.schema.json), and the test fixtures define one versioned protocol. +They use this repository's Apache-2.0 license. + +## How a connection works + +1. The host registers with the provider and proves that it owns its signing key. +2. The host publishes its address, certificate fingerprint, and connection + settings. It sends heartbeats to renew its registration lease. +3. The provider uses that information to give a client a connection answer and + an admission token. +4. The client sends the token in its first STUN packet to the host. +5. The host checks the packet and token, then establishes the connection. The + client's certificate must match the fingerprint in the token. +6. The host reports connection and game outcomes to the provider afterwards. + +Here, **stateless admission** means that the host needs no saved state for that +client before its first packet arrives. The host still keeps its own keys, +registration, and active connections. A conforming implementation MUST NOT +require a push, poll, shared lookup, offer fetch, or pre-staged client state to +admit a client. Reports about the connection or game outcome never determine +whether the host can accept that first packet. + +NXS covers communication between the host and provider. Account systems, +credential issuance, billing, Microsoft login, DNS management, and the policy +for choosing a host are outside this specification. + +### Terms + +| Term | Meaning here | +| --- | --- | +| Host or instance | One running NetherNet server. Its instance ID survives a restart. | +| Provider | The service that registers hosts and gives clients connection information. | +| Service | A provider-assigned registration that can contain one or more instances. | +| Lease | The period for which an instance is eligible to receive new connections. Heartbeats renew it. | +| Generation | A counter advanced on activation. Requests from earlier generations are rejected. | +| Host profile | The address, certificate fingerprint, and other settings clients need to connect. | +| Incarnation | A random ID for one bound UDP endpoint. A newly bound endpoint gets a new ID. | +| Admission | Checking a client's token and first packet before creating its native peer. | +| Key epoch | One version of an admission key, identified by `keyId`. | + +ICE checks network reachability using STUN packets. DTLS authenticates and +encrypts the connection. SCTP carries the data channels over that connection. +A UDP tuple identifies a packet's source address and port at a host endpoint. ## Version and discovery @@ -24,197 +67,296 @@ Transport and game outcomes are asynchronous observations, never admission prere | Stateless capability | `nethernet.stateless-admission.v1` | | Stateless carrier prefix | `NXS1` | -The configured origin MUST use HTTPS; HTTP is permitted only for loopback -development. Origins are normalized by lowercasing scheme/host and omitting default -ports. Credentials, path, query and fragment are not permitted in the configured -origin. Discovery is unauthenticated `GET`. `provider` and `controlOrigin` MUST -equal that origin. Each operation URL MUST have the same origin, no userinfo or -fragment. Clients MUST disable redirects for discovery and credential-bearing calls. -Encoded paths and query strings are signed exactly as transmitted. - -Discovery contains `provider`, `controlOrigin`, arrays `protocols`, `signatures`, -`profiles`, `modes`, an `operations` map, `authorization`, `limits`, and optional -`extensions`. Clients reject an unsupported protocol/profile/signature/mode or -required extension before transmitting any credentials. This profile defines -all operation names in the table below; URL paths are discovered, not hard-coded. -`/v1/nxs/` is a recommended mapping, not a routing requirement. - -`authorization` has `header: "Authorization"` and `schemes` entries containing -`scheme` and supported `modes`. Schemes are `anonymous-proof-of-work` and -`bearer-token`; an implementation need only advertise the schemes it accepts. -Anonymous creation permits `new-service`. Bearer authorization permits -`new-service` and/or `attach-instance`. Token scope, reuse policy and issuance -remain provider decisions. Every flow proves possession of the instance key. - -Limits contain `maxBodyBytes` (at most 65536), `clockSkewMs` (at most 60000), -`heartbeatIntervalMs` (1000–30000), `leaseMs`, and `maxControlPage` (at most 100). -`checkInVersion: 1` negotiates response-driven scheduling. A provider MUST advertise -all limits it enforces, reject oversize bodies, and return errors as -`{"code":"lowercase_machine_code"}` with an appropriate HTTP failure status. -Clients bound response bodies before parsing them. On transient transport failure, -429, 502, 503 or 504, the supplied client retries at most three attempts with -bounded exponential delay and jitter; `Retry-After` seconds over ten cause a -retry-later result. Retries never extend a granted lease or challenge expiry. +### Provider origin and operation URLs + +The configured origin MUST use HTTPS. HTTP is permitted only for loopback +development. Normalize the origin by lowercasing its scheme and host and omitting +default ports. It cannot contain credentials, a path, a query, or a fragment. + +Fetch discovery with an unauthenticated `GET`. Its `provider` and `controlOrigin` +MUST equal the configured origin. Each operation URL MUST have that same origin +and contain no userinfo or fragment. Clients MUST disable redirects for discovery +and for calls that carry credentials. Sign encoded paths and query strings +exactly as transmitted. + +Discovery contains `provider`, `controlOrigin`, the arrays `protocols`, +`signatures`, `profiles`, and `modes`, an `operations` map, `authorization`, +`limits`, and optional `extensions`. Before sending credentials, clients reject +an unsupported protocol, profile, signature, mode, or required extension. + +The [operation table](#operations) defines the operation names. Clients get their +URLs from discovery. `/v1/nxs/` is a recommended path, but providers +can use other paths. + +### Authorization and limits + +`authorization` contains `header: "Authorization"` and a `schemes` array. Each +entry has a `scheme` and its supported `modes`: + +| Scheme | Allowed modes | +| --- | --- | +| `anonymous-proof-of-work` | `new-service` | +| `bearer-token` | `new-service`, `attach-instance`, or both | + +A provider need only advertise the schemes it accepts. It decides how tokens +are issued, what they authorize, and whether they can be reused. Every flow also +requires proof that the instance owns its signing key. + +| Limit | v1 constraint | +| --- | --- | +| `maxBodyBytes` | At most 65536 | +| `clockSkewMs` | At most 60000 | +| `heartbeatIntervalMs` | 1000–30000 | +| `leaseMs` | Advertised lease duration | +| `maxControlPage` | At most 100 | + +`checkInVersion: 1` enables the provider to set the next check-in time in its +response. A provider MUST advertise every limit it enforces, reject oversized +bodies, and return errors as `{"code":"lowercase_machine_code"}` with an +appropriate HTTP failure status. Clients limit response size before parsing. + +On a transient transport failure or HTTP 429, 502, 503, or 504, the supplied +client makes at most three attempts in total. Retries use exponential delays +with jitter and an upper bound. A `Retry-After` value over ten seconds returns +a retry-later result. Retrying never extends a lease or challenge expiry. ## Registration and persistent identity -Generate a fresh P-384 machine signing key for each logical instance. Persist it -before requesting a challenge; no live replicas may share a key/state directory. -An instance restart reuses its own durable state. Images/templates MUST contain -neither machine identity nor DTLS private keys. Clients lock their state directory, -write private state atomically with owner-only permissions and durable file/directory -sync, and stop advertising healthy readiness after persistence failure. - -The challenge request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, -optional `label`, explicit `authorization: {scheme}`, and optional `placement`. -The bearer credential is sent only as `Authorization: Bearer ` to the -challenge operation. It MUST NOT enter JSON, proofs, persistent state, or logs. -`attach-instance` requires bearer authorization and placement. A bearer token -authorizes the service; a client-provided label never grants authority. - -Placement is `{region,pool,tags?}`. Region and pool are immutable routing labels -matching `[A-Za-z0-9_-]{1,32}` and `[A-Za-z0-9_-]{1,64}` respectively. Tags have at -most 16 keys matching `[A-Za-z0-9_.-]{1,32}` and trimmed string values of 1–64 -characters without control characters. Exact placement is bound into the challenge -and revalidated against token authority at atomic completion. No provider selection -algorithm is implied by these fields. - -The public JWK is EC/P-384 with canonical unpadded base64url `x` and `y` encoding -exactly 48 bytes each, and MUST NOT contain `d`. RFC 7638 thumbprint is SHA-256 of -UTF-8 JSON with members exactly `crv,kty,x,y` in that order. ES384 signatures are -96-byte IEEE-P1363 `r || s`, unpadded base64url; DER and noncanonical base64url fail. - -A challenge contains `protocol`, `signature`, `challengeId`, `nonce`, `audience`, -`thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, and -`pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. Difficulty is 0–24; -bearer-authorized and recovery flows use zero. An authorization reference is opaque, -never the credential itself. Expiry/server times use integer epoch milliseconds. - -Canonical arrays are UTF-8 JSON without whitespace or Unicode normalization. -Missing context strings are empty strings. `contextDigest` is unpadded base64url -SHA-256 of `[mode,profile,label,authorizationId,serviceId,region,pool,registrationId]`. -When tags are nonempty, append `tagsDigest`, the same digest of sorted `[key,value]` -pairs. The completion proof is: +### Save the instance key + +Generate a fresh P-384 machine signing key for each logical instance. Save it +before requesting a challenge. A restart reuses that instance's saved state; +live replicas cannot share a key or state directory. Images and templates MUST +contain neither machine identity nor DTLS private keys. + +Clients lock their state directory and write private state atomically with +owner-only permissions. Sync both files and directories to durable storage. +If saving state fails, stop advertising healthy readiness. + +### Request a challenge + +The request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, explicit +`authorization: {scheme}`, and optional `label` and `placement`. + +Send a bearer credential only to the challenge operation, in +`Authorization: Bearer `. It MUST NOT appear in JSON, proofs, saved state, +or logs. `attach-instance` requires both bearer authorization and placement. +The token authorizes access to the service; a client-provided label grants no +permission. + +Placement is `{region,pool,tags?}`: + +| Field | Constraint | +| --- | --- | +| `region` | Immutable routing label matching `[A-Za-z0-9_-]{1,32}` | +| `pool` | Immutable routing label matching `[A-Za-z0-9_-]{1,64}` | +| `tags` | At most 16 keys matching `[A-Za-z0-9_.-]{1,32}`; values are trimmed strings of 1–64 characters with no control characters | + +The challenge binds the exact placement. At completion, the provider rechecks +that the token authorizes it as part of the same atomic operation that creates +the registration. These fields do not prescribe how a provider selects a host. + +The public JWK is EC/P-384. Its `x` and `y` values use canonical, unpadded +base64url and each encode exactly 48 bytes. It MUST NOT contain `d`. The RFC 7638 +thumbprint is SHA-256 of UTF-8 JSON with members in this exact order: +`crv,kty,x,y`. ES384 signatures use the 96-byte IEEE-P1363 form `r || s`, encoded +as unpadded base64url. Reject DER signatures and noncanonical base64url. + +The challenge response contains `protocol`, `signature`, `challengeId`, `nonce`, +`audience`, `thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, +and `pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. + +Proof-of-work difficulty is 0–24. Bearer-authorized and recovery flows use zero. +An authorization reference is an opaque identifier, never the credential itself. +Expiry and server times are integer epoch milliseconds. + +### Complete registration + +Canonical arrays use UTF-8 JSON with no whitespace or Unicode normalization. +Use an empty string for a missing context string. `contextDigest` is the +unpadded base64url SHA-256 digest of: + +```text +[mode,profile,label,authorizationId,serviceId,region,pool,registrationId] +``` + +When tags are nonempty, append `tagsDigest` to that array. Compute `tagsDigest` +in the same way from sorted `[key,value]` pairs. The completion proof is: ```text [protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, expiresAt,proofNonce,idempotencyKey] ``` -PoW counts leading zero bits of SHA-256 over those bytes. Completion sends -`protocol,challengeId,proofNonce,idempotencyKey,signature`. The provider MUST check -expiry, binding, signature, difficulty, current authority and single-use completion -atomically with resource creation. Retrying completion MUST NOT replay one-time -key secrets. Recover an interrupted completion through proof of the same key. +Proof of work counts the leading zero bits in SHA-256 of those bytes. Send +`protocol,challengeId,proofNonce,idempotencyKey,signature` to complete registration. +The provider MUST check expiry, binding, signature, difficulty, current authority, +and single-use completion atomically with resource creation. + +Retrying completion MUST NOT return one-time key secrets again. If completion +was interrupted, recover the registration by proving ownership of the same key. Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, -readiness` and optional one-time `ticketKey` and `extensions`. Persist returned IDs -and key material before activation. Strip secret material from application-facing -registration results and diagnostic output. +readiness`, plus optional one-time `ticketKey` and `extensions`. Save the IDs and +key material before activation. Remove secrets from registration results exposed +to applications and from diagnostic output. ## Signed lifecycle and host profile -Every operational request uses the registered machine key, never the enrollment -bearer token. Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, +### Sign operational requests + +Use the registered machine key for every operational request. The enrollment +bearer token is used only for the challenge request. + +Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, `nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and -`idempotency-key`. Timestamp is epoch milliseconds; generation and sequence are -nonnegative integers. Reserve sequence durably before sending. Authentication binds: +`idempotency-key`. The timestamp is epoch milliseconds. Generation and sequence +are nonnegative integers. Save each reserved sequence number before sending its +request. The signature covers this array: ```text [protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] ``` -The empty body hashes as zero bytes. Providers reject stale generations, reused -sequence numbers, invalid timestamps and signatures. An idempotent retry with the -same intent and unchanged semantic request can return its recorded non-secret result; -it cannot reapply an operation. Accepted activation increments generation and resets -sequence; old processes are fenced. Signed stateful operations belong to the active -profile. Recovery and a signed activation are the explicit profile migration boundary. +For an empty body, hash a zero-length byte sequence. Providers reject stale +generations, reused sequence numbers, invalid timestamps, and invalid signatures. +An idempotent retry can return the recorded result, with secrets removed, if its +intent and semantic request are unchanged. It cannot apply the operation again. + +Activation increments the generation and resets the sequence. The provider then +rejects requests from the old process. Signed state-changing operations must use +the active profile. To change profiles, recover the registration and send a +signed activation request. -| Operation | Request | Required behavior/result | +### Operations + +| Operation | Request | Required result or behavior | | --- | --- | --- | -| `challenges` | POST challenge request, optional bearer | Bound registration challenge | -| `complete` | POST completion proof | Registration or recovered registration; secrets only once | -| `recover` | POST `{registrationId,protocol,profile}` | Challenge for current/pending machine key; preserves assigned IDs | -| `activate` | Signed POST `{profile}` | Incremented `leaseGeneration,leaseDeadline`; resets stale host readiness | -| `readiness` | Signed GET | Routability and reasons, optional extension metadata | -| `host-profile` | Signed POST profile below | Immutable/monotonic `revision`; reject unusable candidates or keys | -| `heartbeat` | Signed POST health/status below | Received time, renewed lease and optional check-in schedule | -| `control` | Signed GET, optional cursor | Bounded `commands`, optional `cursor`, `serverTime` | -| `control/ack` | Signed POST `{cursor}` | Acknowledge only completed terminal lifecycle commands | -| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new epoch | -| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm keys installed before routing with their epoch | -| `ticket-events` / `events` | Signed POST `{events:[...]}` | Idempotent bounded asynchronous observations | -| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof by replacement key | -| `retire` | Signed POST `{keyId}` | Retire previous machine signing key | -| `drain` | Signed POST `{}` | Stop new routing/admissions, preserve existing sessions | -| `deregister` | Signed POST `{}` | Revoke instance from routing; terminate its registration lifecycle | - -Rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, -newThumbprint,generation,idempotencyKey]`. Persist replacement private key before -rotation, then result before retiring the old key. Recovery can resolve an interrupted -rotation using the key thumbprint returned by the provider. +| `challenges` | POST challenge request, optional bearer | Challenge bound to the registration request | +| `complete` | POST completion proof | New or recovered registration; return secrets only once | +| `recover` | POST `{registrationId,protocol,profile}` | Challenge for the current or pending machine key; preserve assigned IDs | +| `activate` | Signed POST `{profile}` | Increment `leaseGeneration`, return `leaseDeadline`, and reset stale host readiness | +| `readiness` | Signed GET | Whether the host can receive new connections, with reasons and optional extension metadata | +| `host-profile` | Signed POST profile below | A `revision` cannot change once published; updates use a higher revision. Reject unusable candidates or keys | +| `heartbeat` | Signed POST health/status below | Receipt time, renewed lease, and optional check-in schedule | +| `control` | Signed GET, optional cursor | Limited `commands` page, optional `cursor`, and `serverTime` | +| `control/ack` | Signed POST `{cursor}` | Acknowledge only lifecycle commands that have finished | +| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new key epoch | +| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm the key is installed before using its epoch for new connections | +| `ticket-events` / `events` | Signed POST `{events:[...]}` | Limited batches of asynchronous observations; retries do not duplicate them | +| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof of ownership of the replacement key | +| `retire` | Signed POST `{keyId}` | Retire the previous machine signing key | +| `drain` | Signed POST `{}` | Stop directing and accepting new connections; preserve existing sessions | +| `deregister` | Signed POST `{}` | Stop directing connections to the instance and end its registration | + +### Rotate a machine key + +The rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, +newThumbprint,generation,idempotencyKey]`. Save the replacement private key before +requesting rotation. Save the result before retiring the old key. After an +interrupted rotation, recovery can use the provider's returned key thumbprint +to identify which key is current. + +### Publish the host profile `host-profile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, `sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. -Incarnation is fresh random 16-byte lowercase hex for each bound native endpoint. -The fingerprint is `sha-256 ` followed by colon-separated uppercase certificate -digest bytes. Candidates contain `foundation,component,protocol,priority,address, -port,type`; only reachable, explicitly advertised UDP candidates may be published. -Bind addresses and advertised addresses are separate concepts. Never advertise -wildcard `0.0.0.0`/`::`. NAT and relay reachability must be established by the -deployment/provider; passing a registration test does not prove reachability. - -The host provisions its DTLS certificate/key before profile publication and keeps -the private key local. All peers represented by a published profile use that -certificate. Machine signing keys, DTLS identities and admission keys are distinct. -An endpoint can use a newly generated identity on a later incarnation after publishing -the new fingerprint; a shared permanent fleet certificate is neither required nor advised. - -Admission keys have `keyId` (four uppercase alphanumeric characters), secret -(32–256 UTF-8 characters), optional `notBefore` and `retireAfter` epoch milliseconds. -Install at most eight epochs atomically, acknowledge them, then publish a profile -using an active installed epoch. Hosts reject before activation/after retirement -and erase retired material. They do not extend token expiry when rotating keys. - -Heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, -clockUnixMillis`, optional `region,serverStatus,checkInVersion`. Capacity and load -are routing observations, independent of advertised player/max-player counts. -Status contains `name,protocol,version,level,players,maxPlayers,gameType`. -Publishing failures do not refresh old status timestamps. Readiness requires current -identity/generation, a live lease, usable fresh host profile and installed key acknowledgment. -Optional product extensions cannot gate core readiness. - -When check-in v1 is negotiated, the heartbeat response has ISO8601 `receivedAt` and -`checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, -controlPollAfterMillis}`. Absolute times are epoch milliseconds. `nextCheckInAt` -precedes lease expiry. Hosts schedule against monotonic clocks and count network -time against the interval; changed activity/status may prompt an earlier rate-limited -heartbeat. Restarts publish immediately and reset old schedules. Provider outage -expires routing leases but does not itself tear down established sessions. - -Lifecycle controls supported by this profile are `noop,drain,suspend,revoke`. -Unknown controls are not silently acknowledged; process later known lifecycle -commands even while an earlier unknown command prevents advancing the page cursor. -`join-admission` is explicitly not a v1 control: admissions never wait for it. -Event batches have at most 100 entries and retain only redacted correlation, -stage/type, timestamp and bounded reason fields. Never send SDP, private keys, -player identity or game payloads as telemetry. Transport establishment is distinct -from `ticket.game_joined` (game play-ready) and `ticket.game_rejected`. +Generate a fresh random 16-byte `incarnation`, encoded as lowercase hex, for +each bound native endpoint. The fingerprint is `sha-256 ` followed by the +certificate's digest bytes in colon-separated uppercase hex. + +Each candidate contains `foundation,component,protocol,priority,address,port,type`. +Publish only reachable UDP candidates that are explicitly chosen for advertisement. +The bind address and the advertised address serve different purposes. A host can +bind to all interfaces, but it cannot advertise wildcard `0.0.0.0` or `::`. +The deployment or provider must establish reachability through NAT or a relay; +a passing registration test does not prove that clients can reach the address. + +Prepare the host's DTLS certificate and key before publishing its profile. Keep +the private key local. All peers using that profile use that certificate, so +clients see the fingerprint the provider advertised. The host may use a new +certificate for a later endpoint incarnation after publishing its new fingerprint. +A permanent certificate shared across a fleet is neither required nor advised. + +Three types of key have separate jobs: + +| Key | Purpose | +| --- | --- | +| Machine signing key | Authenticate the host's requests to the provider | +| DTLS certificate and private key | Authenticate the host during the client connection | +| Admission key | Protect and validate the client's admission token | + +### Install admission keys + +Each key has a `keyId` of four uppercase alphanumeric characters, a `secret` of +32–256 UTF-8 characters, and optional `notBefore` and `retireAfter` times in epoch +milliseconds. Install at most eight epochs atomically and acknowledge them. Then +publish a profile that uses an active, installed epoch. + +Reject tokens before the key's activation time or after its retirement time. +Erase retired key material. Rotating keys does not extend token expiry. + +### Send heartbeats and report readiness + +A heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, +clockUnixMillis` and optional `region,serverStatus,checkInVersion`. Capacity and +load describe routing capacity; they are independent of the advertised player +and maximum-player counts. Status contains +`name,protocol,version,level,players,maxPlayers,gameType`. A failed publication +does not refresh the timestamp of previously published status. + +A host is ready to receive connections only when it has a current identity and +generation, a live lease, a usable fresh host profile, and acknowledged installed +keys. Optional product extensions cannot affect this core readiness check. + +With check-in v1, the heartbeat response contains ISO8601 `receivedAt` and: + +```text +checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, + controlPollAfterMillis} +``` + +Absolute times in `checkIn` are epoch milliseconds. `nextCheckInAt` is before +lease expiry. Hosts use monotonic clocks for scheduling and include network time +in the interval. Changed activity or status can trigger an earlier heartbeat, +subject to the rate limit. On restart, publish immediately and discard the old +schedule. If the provider is unavailable, routing leases expire; existing sessions +are not closed solely because of that outage. + +### Handle controls and report outcomes + +This profile supports `noop,drain,suspend,revoke`. Do not silently acknowledge an +unknown control. An unknown command can prevent advancing the page cursor, but +later known lifecycle commands still need processing. `join-admission` is not a +v1 control; accepting a client never waits for that command. + +Event batches contain at most 100 entries. Keep only redacted correlation data, +stage or type, timestamp, and reason fields with size limits. Never send SDP, +private keys, player identity, or game payloads as telemetry. A working transport +connection is a separate outcome from `ticket.game_joined` (ready to play) or +`ticket.game_rejected`. ## Stateless admission carrier -The client's first STUN USERNAME is `:`. -`answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag)`. -Use standard base64 alphabet (ICE permits `+` and `/`), not base64url. Total ufrag -length is at most 256 characters. Noncanonical encoding, trailing padding, wrong -prefix, unknown epochs and oversized inputs are rejected before allocation. +### Carry the token in the ICE username + +The client's first STUN USERNAME is `:`, where: -AES-256-GCM uses random 12-byte nonce and 16-byte tag. Its key is +```text +answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag) +``` + +Use the standard base64 alphabet, including `+` and `/`, which ICE permits. +Do not use base64url. The total ufrag length is at most 256 characters. Before +allocating peer state, reject noncanonical encoding, trailing padding, a wrong +prefix, unknown key epochs, and oversized input. + +AES-256-GCM uses a random 12-byte nonce and a 16-byte tag. Its key is `HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. -Audience is `nxs-stateless-host-v1/`. AAD is +The audience is `nxs-stateless-host-v1/`. The additional authenticated +data (AAD) is `"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. | Plaintext offset | Size | Meaning, unsigned big-endian where numeric | @@ -228,50 +370,75 @@ Audience is `nxs-stateless-host-v1/`. AAD is | 66 | 1 | Client ICE password length, 22–91 | | 67 | N | Client ICE password in ICE base64 alphabet | -The host's local ICE password is unpadded standard base64 of the first 24 bytes of +The host's local ICE password is the unpadded standard base64 encoding of the +first 24 bytes of `HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. -The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer ufrag, -encoded lowercase hex. Maximum admitted token TTL is 120 seconds; the supplied -implementation uses 60 seconds. Hosts validate expiry, bounds, GCM, client binding -and raw STUN MESSAGE-INTEGRITY before tuple promotion or native peer creation. -The resulting DTLS handshake MUST verify the client fingerprint from the token. - -Only identical-token retransmissions from the same UDP tuple may reuse a reservation. -Token replay from another tuple and conflicting admission on an occupied tuple fail -closed. Bound sessions, pending handshakes, replay cache, callbacks and datagram queues. -Peer creation happens outside the mux callback lock. Deliver/replay the authenticated -first datagram after native registration so the first STUN request receives a response. -Do not release admission capacity until native teardown actually completes. +The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer +ufrag, encoded as lowercase hex. + +### Validate the first packet + +A token can be valid for at most 120 seconds. The supplied implementation uses +60 seconds. Before assigning the UDP tuple to a peer or creating a native peer, +the host checks expiry, field bounds, GCM authentication, client binding, and the +raw STUN MESSAGE-INTEGRITY. The DTLS handshake MUST then verify the client +fingerprint from the token. + +Only a retransmission of the identical token from the same UDP tuple can reuse +a reservation. Reject the same token from another tuple. Also reject a conflicting +admission on an occupied tuple. + +Limit the number of sessions, pending handshakes, replay-cache entries, callbacks, +and queued datagrams. Create peers outside the UDP mux callback lock. After +registering the native peer, deliver or replay the authenticated first datagram +so that its STUN request receives a response. Release admission capacity only +after native teardown has actually finished. ## Optional extensions and compatibility -`extensions` is an object with at most 16 reverse-DNS namespace keys and 16384 bytes -of encoded UTF-8 JSON. Each value is `{version:positiveInteger,critical:boolean,data:object}`. -Namespace keys are lowercase domain-style labels, at most 128 characters. Unknown -optional extensions are passed through/ignored, never automatically executed. -Unsupported critical extensions fail before credentials or activation. Core semantics -cannot be redefined by an optional extension. Bodies and operation paths remain -authenticated by the surrounding TLS/signature boundary. - -An extension may advertise `data.operations` URLs. An application may explicitly -request an operation only after validating its namespace/version and meaning. -The generic transport still enforces same-origin URLs and signs their exact path. -Account claim actions are a product extension; NXS assigns them no core meaning. - -Previously persisted IDs/keys may be recovered into this profile through explicit -`recover {registrationId,protocol,profile}` and signed `activate {profile}`. Verify -the same key and origin, preserve IDs and DTLS files, then atomically record the new -profile/generation. Legacy protocol bytes MUST NOT be relabelled as v1. Providers -may retain separately negotiated legacy adapters; the neutral Java module implements -only NXS. Rollback requires explicit signed profile activation and recovery with the -previous client; never bypass machine authentication or copy a live state directory. +### Extensions + +Providers can add optional application metadata without making it part of NXS. +For example, a product could supply an account-claim link. NXS does not define +what claiming an account means or require other providers to implement it. + +`extensions` is an object with at most 16 reverse-DNS namespace keys, such as +`com.example.feature`, and at most 16384 bytes of encoded UTF-8 JSON. Keys use +lowercase domain-style labels and have at most 128 characters. Each value is +`{version:positiveInteger,critical:boolean,data:object}`. + +Pass through or ignore unknown optional extensions; never execute them +automatically. Reject unsupported critical extensions before sending credentials +or activating. An optional extension cannot change the core protocol rules. +TLS and request signatures still authenticate bodies and operation paths. + +An extension can advertise URLs in `data.operations`. An application can request +one of these operations only after validating the namespace, version, and meaning. +The generic transport still requires the same provider origin and signs the +exact path. + +### Upgrade and rollback + +Recover saved IDs and keys into this profile with +`recover {registrationId,protocol,profile}`, then signed `activate {profile}`. +Verify the same key and origin, preserve IDs and DTLS files, and record the new +profile and generation atomically. Legacy protocol bytes MUST NOT be relabelled +as v1. Providers may keep separately negotiated legacy adapters; the neutral +Java module implements only NXS. + +Rollback uses the previous client with explicit recovery and signed profile +activation. Never bypass machine authentication or copy a live state directory. ## Conformance -`node docs/external-signalling/fixtures.mjs` verifies independent JavaScript signing, -encryption and fixture hashes. `--write` regenerates public test signatures. -The JVM suites consume these exact files via Gradle resources. The independent -provider implements registration, signed lifecycle, status, keys, outcomes, drain -and recovery without a product account system. Native tests separately exercise -raw STUN admission and DTLS transport. Stock-client admission, gameplay and two-host -routing must be reported separately from fixture/native conformance. +Run `node docs/external-signalling/fixtures.mjs` to verify the independent +JavaScript signing, encryption, and fixture hashes. `--write` regenerates public +test signatures. The JVM suites load these same files through Gradle resources. + +The independent test provider covers registration, signed operations, status, +keys, outcomes, drain, and recovery without a product account system. Native +tests separately check raw STUN admission and DTLS transport. + +Report stock-client admission, gameplay, and routing across two hosts separately +from fixture and native tests. Passing those tests does not prove that a stock +client can join and play. diff --git a/docs/technical-writing.md b/docs/technical-writing.md new file mode 100644 index 0000000..f45f710 --- /dev/null +++ b/docs/technical-writing.md @@ -0,0 +1,41 @@ +# Writing for reviewers and implementers + +Explain what changes, why it matters, and how the reader can check it. Assume the +reader understands software but has not followed this project's discussions. + +- Start with the problem and the resulting behavior. Put audit details later. +- Name the actor and action: “the host checks the token,” for example. +- Give each sentence one main idea. Split sentences that ask the reader to hold + several conditions in mind. +- Use familiar words. Keep a technical term when it adds precision, and explain + it on first use. +- Show a small example before a complex rule. Use a table for comparisons or a + sequence for a flow. +- Keep exact API names, field names, limits, and signing formats. Simpler prose + must not weaken a protocol requirement. +- State what the tests establish and what remains untested. Support performance + or reliability claims with measurements. +- Link to detailed history and build records. Keep attribution and dependency + tables where reviewers need them, but avoid repeating full commit hashes in + prose. + +For a PR, lead with the behavior change, then explain the approach and relevant +validation. For an aggregate proposal, also show who contributed each part and +which future PRs depend on others. Describe the final change rather than the +sequence of attempts used to build it. + +For a specification, start with its purpose, a short example, and the terms a +reader needs. Follow with the exact rules. Keep optional product behavior clearly +identified so an independent implementation knows what it needs to support. + +| Before | After | +| --- | --- | +| “Old processes are fenced.” | “After activation, the provider rejects requests from the previous generation.” | +| “Bounded namespaced extensions.” | “Optional metadata uses named extensions with limits on their number and size.” | +| “Persistent endpoint identity.” | “The host reuses its DTLS certificate, so clients see the advertised fingerprint.” | + +Before publishing, read the opening paragraph aloud. A reviewer should be able +to explain the benefit without first reading the implementation. + +This checklist follows [Google's advice on short sentences](https://developers.google.com/tech-writing/one/short-sentences) +and [Microsoft's style and voice guidance](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice).