quest'n'click 4.5.0 · r1

Construite pour quest'n'click 4.5.0, révision r1. Si elle est publiée, et laquelle est la courante, est dit par publication.json.

Sécurité : Biscuit et signatures

English · Type : explication · Public : administrateur · Contexte : Reality Bridge · Statut : prête · S'applique depuis : 4.2.5

Le Reality Bridge, profil local (SQLite) · contrat : stable · vérifié par la CI (matrice de support de la 4.4.0, 2026-10-23) : Node 22.13+ avec node:sqlite, sous Ubuntu ; Chromium et WebKit de bout en bout · vérifications automatiques : jobs reality, reality-xcheck ; plan de CI reality ; 2 fichiers de tests · passes humaines : aucune · limites connues : bridge-rotation-resets-run-quota

Le Reality Bridge, profil distributed (Postgres) · contrat : expérimental · vérifié par la CI (matrice de support de la 4.4.0, 2026-10-23) : Postgres 16 en CI ; Le profil de qualification : HTTPS, trois instances (ops/qualify/) · vérifications automatiques : jobs bridge-postgres, deployment-smoke ; plan de CI reality ; 1 fichier de tests · passes humaines : bridge-postgres-https pas faite (#77) · limites connues : bridge-rotation-resets-run-quota

Les connecteurs (email, Telnet, SSH, Open Badges) · contrat : expérimental · vérifié par la CI (matrice de support de la 4.4.0, 2026-10-23) : Node 22 sous Ubuntu (Telnet et SSH pas sous Windows) · vérifications automatiques : jobs connectors, connector-archive-e2e ; plan de CI reality ; 3 fichiers de tests · passes humaines : connectors-real-security pas faite (#79) · limites connues : bridge-rotation-resets-run-quota

Comment le Reality Bridge décide qui peut proposer un signal, comment un joueur sait que le Bridge l’a accepté, quels secrets existent et où ils vivent, comment les clés tournent et les jetons sont révoqués, et que faire quand l’un d’eux fuit. Deux mécanismes, chacun pour une question : Biscuit dit qui peut proposer quoi, sur le Bridge seulement ; une signature Ed25519 dit ce que le Bridge a accepté, vérifiée par le joueur.

Les secrets et où ils vivent

SecretOùQui le litS’il fuit
La moitié privée de la racine Biscuitroot.key (mode 600)grant seulement ; serve ne la charge jamaisN’importe qui peut fabriquer des jetons de connecteur : une nouvelle racine
La clé qui signe les événements (Ed25519)config.json (eventKey, mode 600)serveN’importe qui peut signer des signaux que les joueurs acceptent : la faire tourner
Le jeton de l’opérateuradmin-token (seule copie en clair) ; son SHA-256 dans config.jsonrevoke, les routes /v1/admin/*N’importe qui peut exporter, supprimer et révoquer. Aucune commande ne le remplace seul : en créer un avec init dans un dossier de travail, copier son adminTokenHash dans config.json et son admin-token à côté, redémarrer
Le Biscuit d’un connecteurle tokenFile du connecteurce connecteurLe révoquer par son id, en donner un nouveau
Le secret et le jeton d’un webhook de démonstrationconfig.json (webhooks)serveRefaire init, ou ne jamais les activer sur Internet (--no-demo-webhooks)
La capacité d’un joueurle navigateur du joueur (localStorage) ; son SHA-256 sur le Bridgela page du joueurRévoquer le lien du joueur
L’URL et le mot de passe de la baseBRIDGE_STORE ou BRIDGE_STORE_FILE, jamais le fichierserve et les commandesChanger le mot de passe

Gardez root.key là où le Bridge n’est pas, si vous le pouvez : un Bridge qui ne fait que servir n’en a pas besoin.

Biscuit : qui peut proposer quoi

Le jeton d’un connecteur est fabriqué par grant à partir de la clé racine. Son bloc d’autorité nomme le jeu, les sources, les signaux, les joueurs (ou n’importe quel joueur du jeu), l’audience du Bridge, le tenant (depuis la 4.1.10), s’il peut confirmer des codes d’appairage (--pair), et une expiration à la seconde (refusé à partir de cette seconde). Le Bridge vérifie chaque proposition contre bridge/policy/propose.datalog, et chaque confirmation d’appairage contre pair.datalog, en 200 ms au plus de temps d’exécution Biscuit.

Garantie · bridge-bad-token-before-player · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Un mauvais jeton est refusé avant qu’un joueur inconnu ne soit nommé, et une adresse à court d’authentifications ratées attend.

Signatures : ce que le Bridge a accepté

Le Bridge signe chaque signal accepté sous forme de JWS compact (alg: EdDSA, un kid qui nomme la clé). Le joueur vérifie la signature sur les octets exacts qu’il a reçus avant de les lire, puis la taille, la fenêtre de la clé, le jeu, le joueur, le manifeste et l’expiration, avec cinq minutes de tolérance pour l’horloge de l’appareil. Le joueur ne détient ni Biscuit ni clé : seulement une capacité qui lit et acquitte ses propres signaux.

Un WorldSignalV2 nomme aussi son tenant, son environnement, l’origine depuis laquelle le joueur s’est appairé, le lien et la clé, si bien qu’un signal signé pour un contexte est refusé dans un autre (audience-mismatch), même quand deux tenants ont reçu (à tort) la même clé. Un Bridge qui sert plusieurs tenants ne signe que du V2, et liste ses clés avec leur tenant ; un Bridge à un seul tenant signe du V1 sauf si sa configuration dit signalVersion: 2 (init --signal-version=2). Le joueur accepte les deux par défaut. Chaque champ : Signaux et manifeste.

Le webhook de démonstration (POST /v1/hooks/<source>) s’authentifie autrement : un HMAC-SHA256 du corps brut sous le secret du webhook, dans X-Web-Scumm-Signature: sha256=<hex>, comparé en temps constant.

Clés et rotation

La clé d’événements signe chaque signal avec son kid ; le joueur fait confiance aux clés que liste GET /v1/keys, chacune dans sa fenêtre.

# dans le dépôt du moteur (le dossier du Bridge)
npm run bridge -- rotate --keep-days=30

rotate crée une nouvelle clé et garde l’actuelle dans previousKeys pendant --keep-days jours ; redémarrez ensuite le Bridge. Dès lors, le Bridge livre chaque signal sous sa clé actuelle : ce qui attendait un joueur est re-signé à la livraison (le stockage garde la charge telle qu’acceptée), et un joueur dont le lien était ouvert redemande les clés une fois quand un signal en nomme une qu’il ne connaît pas. --keep-days n’a qu’à couvrir un joueur qui a reçu un signal juste avant la rotation et le vérifie après.

Limite · bridge-rotation-resets-run-quota Les compartiments de débit des classements de speedrun sont indexés par un HMAC dérivé de la clé d’événements : la faire tourner fait repartir chaque client sur un nouveau compartiment (au plus une minute de quota en plus).

La clé racine Biscuit fabrique les jetons des connecteurs : la faire tourner, c’est redonner un nouveau jeton à chaque connecteur.

Révocation

# dans le dépôt du moteur, le Bridge en marche
npm run bridge -- revoke --url=<bridge> --player=<p-…>           # le lien d'un joueur
npm run bridge -- revoke --url=<bridge> --token=<revocation id>  # le jeton d'un connecteur (n'importe lequel de ses ids de révocation)

Les flux d’un joueur révoqué se terminent aussitôt, et ses propositions sont refusées, y compris une qui chevauche la révocation (le joueur et le jeton sont revérifiés dans l’étape par joueur). Un joueur peut révoquer son propre lien : « Unlink » dans le menu pause (POST /v1/unlink). Une capacité finit aussi d’elle-même : 30 jours après son dernier accusé de réception, 180 jours au plus après son appairage.

Procédures

La clé d’événements d’un tenant compromise. rotate --dir=<tenant>, puis retirez la clé compromise de previousKeys dans le config.json de ce tenant, et redémarrez ses instances. Les joueurs refusent dès lors ce qu’elle a signé ; ce qui les attend est re-signé sous la nouvelle clé à la livraison. Les autres tenants ne sont pas touchés.

Une racine Biscuit compromise. init --force en fait trop. Créez une nouvelle racine (init dans un dossier de travail, copiez son biscuitRoot dans config.json et son root.key), redonnez un jeton à chaque connecteur du tenant, redémarrez, puis révoquez les identifiants des anciens jetons (revoke --token=) pour la période d’avant le redémarrage.

Une instance ou son hôte compromis. Elle détenait toutes les clés privées des tenants qu’elle servait : faites tourner la clé d’événements de chaque tenant, créez à chaque tenant une nouvelle racine, changez le mot de passe de la base et les jetons d’opérateur, et lisez la quarantaine du stockage (doctor) et les journaux depuis la compromission.

La rotation, en routine. Par tenant, à son rythme : rotate --dir=<tenant> --keep-days=30, puis redémarrez ses instances une à une (les autres continuent de servir ; le stockage est le même).

Pourquoi

Un signal passe par trois mains : un connecteur, le Bridge, le navigateur du joueur. Chacune doit pouvoir vérifier la précédente, et un jeton volé à une étape ne doit pas ouvrir les autres. Le Biscuit d’un connecteur n’ouvre rien côté joueur ; la capacité d’un joueur ne propose rien ; la clé d’événements signe mais n’autorise aucun connecteur.

Les options écartées

Les compromis

Deux formats à maintenir, des clés à faire tourner, et une vérification croisée de Biscuit en Rust dans la CI (npm run reality:xcheck : la version WebAssembly utilisée en production et la crate Rust doivent s’accorder sur les exemples officiels de Biscuit). Une capacité dans un navigateur est lisible par tout script de la page : elle est étroite (lire et acquitter les signaux d’un joueur) et finit d’elle-même, mais ce n’est pas un secret que la page peut se cacher à elle-même.

Décisions et ADR


Modifier la source de cette page · Signaler une erreur