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.
-
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 : unALLOWest toujoursforced_command: None, non par raccourci d’implémentation mais parce qu’aucune contrainte n’est représentable. -
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_keysréel, oùcommand=etrestrictsont la norme sur les clés de service. -
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 OpenSSH | Nature | v2 |
|---|---|---|
command="…" | commande forcée | ✅ |
restrict | baseline deny-all | ✅ (champ explicite) |
no-pty | restriction nommée | ✅ |
no-port-forwarding | restriction nommée | ✅ |
no-agent-forwarding | restriction nommée | ✅ |
no-X11-forwarding | restriction nommée | ✅ |
no-user-rc | restriction nommée | ✅ |
pty, port-forwarding, agent-forwarding, X11-forwarding, user-rc | ré-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
restrictaujourd’hui doit rester deny-all quand une sixième restriction apparaîtra. Désucrée, elle autoriserait la nouveauté ; - fidélité d’affichage —
air-sshd authorized-keys listdoit 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 —
@3est neuf ;@0–@2sont inchangés ; - la v1 reste lisible — un
AuthorizedKeyécrit sansoptionsprésente un pointeur nul, que Cap’n Proto rend comme une structure par défaut :forcedCommandvide, 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
ALLOWdé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, refuserdirect-tcpip/tcpip-forward, refuserauth-agent-req@openssh.com, refuserx11-req, ne pas exécuter lercutilisateur.
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 :
Stocker— refusée. Unfrom=maintenant, l’appliquer plus tardlistqui affichefrom="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.Renoncer à— refusée : c’est une option de sécurité utile.from=- ✅ 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 addaccepte 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. listrend 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_keysd’Air exprime enfin une politique par clé. Un administrateur peut migrer un~/.ssh/authorized_keysréel portantcommand=etrestrictsans perdre sa politique ni se voir opposer un refus global. - Un
ALLOWest 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-schemareste un schéma bespoke de service (option B d’ADR-096) :air-config-scheman’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. restrictdésucré en cinqno-*à 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 coupleno/permitretenu (qui la représente sans enum supplémentaire), et moins lisible en Cap’n Proto — où unBoolcoûte un bit et unenumun 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).