Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-128 — Options authorized_keys : périmètre, schéma v2, et placement de l’enforcement

Statut : Accepté (2026-07-28, autorisation BDFL). Lève le différé consigné dans les Conséquences d’ADR-096 (« Différés : options authorized_keys (from=, command=, no-pty…) — évolution additive du schéma ») et tranche les deux décisions remontées au superviseur par l’incrément V2.4 d’ADR-124.

Catégorie : Architecture de service (air-sshd, couche 2).

Compagnons : ADR-096 (le magasin — qui reste l’autorité sur son emplacement, son enveloppe et son lecteur), ADR-104 (CLI d’administration et import texte), ADR-124 §D5 (le monitor décide), ADR-012 (discipline de versioning du schéma), ADR-073 (jamais de config texte vivante).

Contexte

Le magasin authorized_keys d’Air est binaire (ADR-096) : enveloppe AIRCFGv1 + message Cap’n Proto SshdAuthorizedKeys. Sa v1 ne porte qu’un triplet par clé — algorithm, keyBlob, comment. Les options d’OpenSSH (command="…", from="…", no-pty, restrict…) y étaient explicitement différées.

Trois faits ont rendu ce différé intenable.

  1. La décision d’autorisation est déjà au bon endroit. ADR-124 §D5 confie au monitor (le processus de confiance) la lecture du magasin et la décision ; le fil RPC porte déjà AuthorizeAllow{forced_command}. Tout est câblé — sauf la source de la contrainte, qui n’existe pas dans le format. Conséquence factuelle de V2.4 : un ALLOW est toujours forced_command: None, non par raccourci d’implémentation mais parce qu’aucune contrainte n’est représentable.

  2. Le CLI d’import ment par omission dès qu’il grandit. ADR-104 (K.1) fait du parseur texte OpenSSH la rampe d’accès au magasin. Ce parseur refuse aujourd’hui toute ligne portant des options (OptionsUnsupported) — refus honnête tant que rien n’est représentable, mais un mur pour quiconque migre un ~/.ssh/authorized_keys réel, où command= et restrict sont la norme sur les clés de service.

  3. Refuser n’est pas la même chose que promettre. Accepter d’enregistrer une option qu’on n’applique pas serait afficher une restriction que le serveur ne tient pas — la pire des trois postures (ADR-104 §5(b) : une garantie non honorée est une fausse promesse, pas une fonctionnalité partielle).

Décision

1. Périmètre v2 — sept options, et pas une de plus

Le schéma v2 porte, par clé :

Option OpenSSHNaturev2
command="…"commande forcée
restrictbaseline deny-all✅ (champ explicite)
no-ptyrestriction nommée
no-port-forwardingrestriction nommée
no-agent-forwardingrestriction nommée
no-X11-forwardingrestriction nommée
no-user-rcrestriction nommée
pty, port-forwarding, agent-forwarding, X11-forwarding, user-rcré-autorisations sous restrict✅ (cf. §2)
from="…"contrôle d’origine❌ — V2.5 (cf. §5)
environment=, permitopen=, permitlisten=, tunnel=, principals=, expiry-time=, verify-required, cert-authority❌ hors périmètre

Toute option inconnue ou hors périmètre est refusée à l’écriture, avec un message nommant l’option. Jamais ignorée en silence : une option acceptée puis oubliée est exactement la fausse promesse que §3 d’ADR-104 proscrit.

2. restrict est un champ, pas du sucre d’écriture

OpenSSH définit restrict comme « toutes les restrictions, y compris celles qui seront ajoutées plus tard », puis autorise des ré-autorisations nommées (pty, port-forwarding, agent-forwarding, X11-forwarding, user-rc).

Le désucrer à l’écriture (restrict ⟶ les cinq no-* du jour) casserait cette sémantique sur ses deux faces :

  • future-proof — une clé écrite restrict aujourd’hui doit rester deny-all quand une sixième restriction apparaîtra. Désucrée, elle autoriserait la nouveauté ;
  • fidélité d’affichageair-sshd authorized-keys list doit rendre ce que l’administrateur a écrit (restrict,pty), pas sa projection du jour (no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc).

Le schéma stocke donc trois familles, et la résolution se fait à la décision :

restrictBaseline : Bool           # « restrict » a été demandé
no<Capability>   : Bool  (× 5)    # refus explicite
permit<Capability> : Bool (× 5)   # ré-autorisation explicite (n'a de sens que sous restrict)

Règle de résolution (une seule, appliquée capacité par capacité) :

autorisé(c) = (permit(c) OU NON restrictBaseline) ET NON no(c)

C’est-à-dire : hors restrict, tout est permis sauf ce qui est explicitement refusé ; sous restrict, rien n’est permis sauf ce qui est explicitement ré-autorisé ; et un refus explicite l’emporte toujours sur une ré-autorisation (fail-closed — le doute ferme). Une capacité future sera refusée sous restrictBaseline par construction : son permit sera faux dans les magasins existants.

3. Schéma v2 — additif, et la v1 reste lisible

AuthorizedKey gagne un champ, d’@id neuf, pointant une structure dédiée :

struct AuthorizedKey {
  algorithm @0 :Text;
  keyBlob   @1 :Data;
  comment   @2 :Text;
  options   @3 :AuthorizedKeyOptions;   # ADDITIF (ADR-128)
}

Trois propriétés, toutes exigées par ADR-012 :

  • ids jamais réutilisés@3 est neuf ; @0@2 sont inchangés ;
  • la v1 reste lisible — un AuthorizedKey écrit sans options présente un pointeur nul, que Cap’n Proto rend comme une structure par défaut : forcedCommand vide, tous les booléens faux ⇒ aucune option, ce qui est exactement la sémantique v1 ;
  • rupture ⇒ type coexistant — inchangé : le jour d’une incompatibilité réelle, on crée SshdAuthorizedKeysV2, on ne retype rien.

Le champ applicatif version suit une règle de version minimale. Le magasin est écrit en version = 1 tant qu’aucune clé ne porte d’option, et en version = 2 dès qu’une clé en porte. Le lecteur accepte {1, 2}. Ce n’est pas une coquetterie :

  • un binaire antérieur (lecteur v1) refuse en bloc un magasin version = 2 — il ne peut donc jamais appliquer une clé en ignorant sa restriction (fail-closed) ;
  • et il continue de lire les magasins sans option, qui sont la majorité — un retour arrière de binaire ne coupe pas l’authentification de tout le parc.

La version étant fonction du contenu seul, l’artefact reste reproductible (ADR-025) et le tri/déduplication canoniques d’ADR-096 sont inchangés.

Bornes. forcedCommand est borné à 4 096 octets au décodage (au-delà : magasin refusé, fail-closed) comme à l’écriture (message explicite). Une commande forcée réelle fait quelques dizaines d’octets ; la borne tient largement, et garantit que la contrainte tient dans un champ du canal RPC monitor (MAX_FIELD_LEN = 64 Kio).

Une commande forcée ne peut pas non plus contenir de saut de ligne : le format authorized_keys sépare ses entrées par \n, si bien qu’une telle commande serait affichée par list sur deux lignes — donc non ré-importable. Air ne stocke pas ce qu’il ne saurait pas réafficher fidèlement. Le cas n’est atteignable que par les drapeaux de la CLI (le format texte est découpé en lignes avant d’arriver au parseur) ; il y est refusé, et le lecteur le revérifie — ce que l’écrivain garantit, le lecteur ne s’y fie pas. Le guillemet et la contre-oblique, eux, se ré-échappent et passent sans problème.

4. Où l’enforcement vit — le monitor décide, le worker applique

Inchangé par rapport à ADR-124 §D5, et rendu concret ici :

  • Le monitor (root borné, processus de confiance) lit le magasin sous l’identité de l’utilisateur, trouve la clé, résout les options selon §2, et répond un ALLOW déjà contraint. L’enfant pré-auth ne voit jamais les options brutes : il reçoit une commande forcée éventuelle et un jeu de permissions résolues — cinq booléens, pas une politique à ré-interpréter.
  • Le worker post-auth (V2.5) applique : lancer la commande forcée au lieu de la commande demandée, refuser pty-req, refuser direct-tcpip/tcpip-forward, refuser auth-agent-req@openssh.com, refuser x11-req, ne pas exécuter le rc utilisateur.

Cet incrément livre la première moitié, de bout en bout : le schéma, l’écriture, la lecture, la décision et le transport de la contrainte. Il ne livre pas son exécution — le worker post-auth n’existe pas encore (V2.5). C’est une frontière assumée et visible : une option écrite aujourd’hui est transportée jusqu’au point d’application, où elle attendra V2.5. Elle n’est jamais présentée comme appliquée.

5. from=non stocké, livré en V2.5 avec son enforcement

from="motif" restreint l’origine de la connexion. Il exige une donnée qu’AuthorizeRequest ne porte pas : l’adresse du pair. Et cette adresse ne peut pas venir de l’enfant pré-auth — il est traité comme hostile, il l’inventerait.

Trois postures étaient possibles ; le BDFL a tranché la troisième :

  1. Stocker from= maintenant, l’appliquer plus tardrefusée. Un list qui affiche from="10.0.0.0/8" sur une clé que le serveur ouvre à toute adresse est une fausse promesse (ADR-104 §5(b)) : la posture même que cet ADR proscrit à la §1.
  2. Renoncer à from= — refusée : c’est une option de sécurité utile.
  3. Livrer from= en V2.5, en un seul additif : le champ de schéma, l’adresse du pair de confiance ajoutée à AuthorizeRequest (renseignée par le monitor depuis le socket, jamais par l’enfant), et l’application, ensemble. Aucune fenêtre de mensonge.

D’ici là, from= est refusé à l’écriture, avec un message qui le dit : « from= non supporté avant V2.5 ». C’est le statu quo d’ADR-104, maintenu délibérément — et non un oubli.

6. Surface d’administration (CLI)

  • air-sshd authorized-keys add accepte les options par drapeaux : --command "…", --restrict, --no-pty, --no-port-forwarding, --no-agent-forwarding, --no-x11-forwarding, --no-user-rc, et les ré-autorisations --permit-pty, --permit-port-forwarding, --permit-agent-forwarding, --permit-x11-forwarding, --permit-user-rc.
  • Le parseur texte OpenSSH (add --line, add --file, import) lit désormais la liste d’options en tête de ligne (virgules, valeurs entre guillemets avec échappement \", noms insensibles à la casse, comme OpenSSH). Options du périmètre §1 ⇒ enregistrées ; from= ⇒ refus nommé (§5) ; toute autre ⇒ refus nommé. Un drapeau et une ligne portant des options sont exclusifs : on ne fusionne pas deux sources de politique.
  • list rend les options au format OpenSSH, dans l’ordre canonique (restrict, ré-autorisations, refus, command), avant l’algorithme.

Le gate administrateur d’ADR-101 est inchangé : écrire dans le magasin d’autrui ou dans le magasin système exige le mot de passe administrateur, avant toute écriture.

7. Correction d’une citation erronée (dette de rédaction)

sshd-authorized-keys.capnp et ADR-124 renvoient au « ADR-096 §6 » pour le différé des options. La §6 d’ADR-096 s’intitule « Production d’artefacts » et ne parle pas d’options : le différé est consigné dans ses Conséquences. Les deux renvois sont corrigés et pointent désormais ce présent ADR, qui lève le différé.

Conséquences

  • Le magasin authorized_keys d’Air exprime enfin une politique par clé. Un administrateur peut migrer un ~/.ssh/authorized_keys réel portant command= et restrict sans perdre sa politique ni se voir opposer un refus global.
  • Un ALLOW est un contrat clos : commande forcée + cinq permissions résolues. L’enfant pré-auth ne ré-interprète rien, et l’ajout d’une restriction future ne changera ni son code ni le protocole — seulement la résolution, côté privilège.
  • Dette explicite, datée, et bornée : l’application (V2.5). Tant que le worker post-auth n’est pas câblé, une option est stockée et transportée, pas appliquée. Le contraire d’une omission : c’est écrit ici, dans ADR-124 §D5, et dans le suivi. Cette dette se solde en même temps que from=.
  • Le lecteur v1 (binaire antérieur) refuse un magasin porteur d’options plutôt que de les ignorer. Un retour arrière de binaire coupe l’authentification par les clés contraintes — jamais il ne les ouvre en grand.
  • air-sshd-schema reste un schéma bespoke de service (option B d’ADR-096) : air-config-schema n’est toujours pas pollué par une notion propre à sshd.
  • Le parseur d’options texte est une nouvelle surface de décodage sur données externes : il est fuzzé (ADR-030) au même titre que le décodeur binaire, et il ne s’exécute jamais sur le chemin d’authentification (import hors ligne, ADR-073).

Alternatives rejetées

  • Une List(Text) d’options brutes dans le schéma. Rejetée : elle repousserait le parsing (donc l’ambiguïté, donc les erreurs) dans le processus privilégié, à chaque authentification — l’inverse exact de la doctrine « parser une fois, hors ligne » (ADR-073/ADR-096). Le schéma binaire doit être déjà décidé, pas re-parsable.
  • restrict désucré en cinq no-* à l’écriture. Rejetée pour les deux raisons de §2 : perte du future-proof et perte de fidélité d’affichage.
  • Une politique tri-état (Autorisé/Refusé/Non dit) par capacité. Rejetée : strictement équivalente au couple no/permit retenu (qui la représente sans enum supplémentaire), et moins lisible en Cap’n Proto — où un Bool coûte un bit et un enum un mot.
  • Stocker from= sans l’appliquer. Rejetée : cf. §5, posture 1.
  • Écrire toujours version = 2. Rejetée : casse la lecture des magasins sans option par tout binaire antérieur, sans le moindre gain de sûreté (§3).