Skip to content
quest'n'click

Reality BridgeExplanationFor adminsApplies from 4.2.5

Security: Biscuit and signatures

Cette page en françaisThis page on the GitHub wiki

How the Reality Bridge decides who may propose a signal, how a player knows the Bridge accepted it, which secrets exist and where they live, how keys rotate and tokens are revoked, and what to do when one leaks. Two mechanisms, each for one question: Biscuit says who may propose what, on the Bridge only; an Ed25519 signature says what the Bridge accepted, verified by the player.

The secrets and where they live

SecretWhereWho reads itIf it leaks
The Biscuit root's private halfroot.key (mode 600)grant only; serve never loads itAnyone can mint connector tokens: a new root
The event-signing key (Ed25519)config.json (eventKey, mode 600)serveAnyone can sign signals players accept: rotate
The operator's tokenadmin-token (the only copy in clear); its SHA-256 in config.jsonrevoke, the /v1/admin/* routesAnyone can export, delete and revoke. No command replaces it alone: make one with init in a scratch folder, copy its adminTokenHash into config.json and its admin-token beside it, restart
A connector's Biscuitthe connector's tokenFilethat connectorRevoke it by id, grant a new one
A demonstration webhook's secret and tokenconfig.json (webhooks)serveRe-init, or never enable them on the Internet (--no-demo-webhooks)
A player's capabilitythe player's browser (localStorage); its SHA-256 on the Bridgethe player's pageRevoke the player's link
The database URL and passwordBRIDGE_STORE or BRIDGE_STORE_FILE, never the fileserve and the commandsChange the password

Keep root.key where the Bridge is not, if you can: a Bridge that only serves does not need it.

Biscuit: who may propose what

A connector's token is minted by grant from the root key. Its authority block names the game, the sources, the signals, the players (or any player of the game), the Bridge's audience, the tenant (from 4.1.10), whether it may confirm pairing codes (--pair), and an expiry to the second (refused from that second on). The Bridge checks each proposal against bridge/policy/propose.datalog, and each pairing confirmation against pair.datalog, within 200 ms of Biscuit's run time.

  • A connector can attenuate its own token (fewer players, an earlier expiry) before handing it on; it can never widen it.
  • Every token has revocation ids; revoking any of them refuses the token.
  • The order of checks gives nothing away: the manifest answers first (it is public), then the token, before any player is named, so a caller without a valid token learns nothing of which players exist (a 401 or 403 before any 404).
  • An address that fails 60 authentications in a minute has every request refused for a while (429).
  • Biscuit only says who may propose. A connector checks what it claims itself: a badge's proof, an email's sender.

Signatures: what the Bridge accepted

The Bridge signs every accepted signal as a compact JWS (alg: EdDSA, a kid naming the key). The player verifies the signature on the exact bytes it received before it parses them, then the size, the key's window, the game, the player, the manifest and the expiry, with five minutes of tolerance for the device's clock. The player holds neither a Biscuit nor a key: only a capability that reads and acknowledges its own signals.

A WorldSignalV2 also names its tenant, environment, the origin the player paired from, the link and the key, so a signal signed for one context is refused in another (audience-mismatch), even when two tenants were (wrongly) given one key. A Bridge serving several tenants signs V2 only, and lists its keys with their tenant; a single-tenant Bridge signs V1 unless its configuration says signalVersion: 2 (init --signal-version=2). The player accepts both by default. Every field: Signals and the manifest.

The demonstration webhook (POST /v1/hooks/<source>) is authenticated otherwise: an HMAC-SHA256 of the raw body under the webhook's secret, in X-Web-Scumm-Signature: sha256=<hex>, compared in constant time.

Keys and rotation

The event key signs every signal with its kid; the player trusts the keys GET /v1/keys lists, each within its window.

# in the engine repository (the Bridge's folder)
npm run bridge -- rotate --keep-days=30

rotate makes a new key and keeps the current one in previousKeys for --keep-days days; then restart the Bridge. From then on the Bridge delivers every signal under its current key: what was waiting for a player is signed again at delivery (the store keeps the payload as accepted), and a player whose link was open asks for the keys once when a signal names one it does not know. --keep-days only has to cover a player that received a signal just before the rotation and verifies it after.

The Biscuit root key mints connector tokens: rotating it means granting every connector a new token.

Revocation

# in the engine repository, the Bridge running
npm run bridge -- revoke --url=<bridge> --player=<p-…>           # a player's link
npm run bridge -- revoke --url=<bridge> --token=<revocation id>  # a connector's token (any of its revocation ids)

A revoked player's streams end at once, and its proposals are refused, including one that overlaps the revocation (the player and the token are checked again inside the per-player step). A player can revoke its own link: the pause menu's "Unlink" (POST /v1/unlink). A capability also ends on its own: 30 days after its last acknowledgement, 180 days after its pairing at most.

Procedures

An event key compromised (one tenant). rotate --dir=<tenant>, then remove the compromised key from previousKeys in that tenant's config.json, and restart its instances. Players refuse what it signed from then on; what waits for them is signed again under the new key at delivery. The other tenants are untouched.

A Biscuit root compromised. init --force is too much. Make a new root (init in a scratch folder, copy its biscuitRoot into config.json and its root.key), grant every connector of the tenant a new token, restart, then revoke the old tokens' ids (revoke --token=) for the window before the restart.

An instance or its host compromised. It held every private key of the tenants it served: rotate each tenant's event key, make each tenant a new root, change the database password and the operator tokens, and read the store's quarantine (doctor) and the logs since the compromise.

Rotation, routinely. Per tenant, at its own pace: rotate --dir=<tenant> --keep-days=30, then restart its instances one by one (the others keep serving; the store is the same).

Why

A signal crosses three hands: a connector, the Bridge, the player's browser. Each must be able to check the one before, and a token stolen at one step must not open the others. A connector's Biscuit opens nothing on the player's side; a player's capability proposes nothing; the event key signs but authorises no connector.

Alternatives set aside

  • COSE for the signed signal: a CBOR decoder in the player for nothing a JWS lacks here; the fallback if a supported browser drops Ed25519 (D15).
  • Biscuit in the player: the player holds a narrow capability instead (D16).
  • Per-tenant keys derived by construction: the player cannot check a derivation whose root it does not hold; a signal names its context instead (ADR 0010).

Trade-offs

Two formats to keep, keys to rotate, and a Rust cross-check of Biscuit in CI (npm run reality:xcheck: the WebAssembly build used in production and the Rust crate must agree on Biscuit's official samples). A capability in a browser is readable by any script of the page: it is narrow (read and acknowledge one player's signals) and ends on its own, but it is not a secret the page can keep from itself.

Decisions and ADRs