ADR-141 — Registre de durcissement fast-follow post-clôture ADR-109
Statut : Accepté (2026-07-30, décision BDFL). RFC de suivi (ADR-015).
S’appuie sur ADR-109 (certificats SSH + CA), ADR-139 (cert user privsep),
ADR-140 (émission + clé CA privée), ADR-108 (air-keystore).
Catégorie : registre de dette de sécurité assumée et planifiée (couches 1 et 2).
Aucun de ces items n’est bloquant ; aucun n’est exploitable sans la clé privée de la
CA. Ils sont consignés ici pour être ordonnancés pendant la vague 2 (avec ADR-115 — CLI
air-keystore — ou la campagne de tests de sécurité), et non perdus dans des notes.
Contexte
La clôture d’ADR-109 (Inc.1→Inc.5, déployée en air-sshd 0.6.0) a produit, via ses revues adverses, une petite liste de durcissements délibérément différés (hors périmètre de l’incrément qui les a révélés, ou transverses à plusieurs chemins). Le BDFL exige qu’ils vivent dans un ADR — pas seulement dans la mémoire de travail ou des notes. Ce document en est le registre ; il s’enrichira des durcissements futurs de même nature.
Décision : items de durcissement (différés, planifiés)
D1 — ✅ FAIT (#539) — cert.rs::parse : rejeter les options critiques dupliquées ou hors ordre (O2)
Livré : #539. Le rejet couvre les options critiques et les extensions (même règle
de format, parité PROTOCOL.certkeys), à l’analyse comme à l’émission (issue refuse un nom
en double, qu’aucun tri ne peut réparer).
air_ssh_proto::cert::parse (parse_tuples) n’impose ni unicité ni tri lexicographique des
options critiques d’un certificat. OpenSSH, lui, refuse un cert dont les options critiques
sont « duplicated or out-of-order » (PROTOCOL.certkeys). Chez Air, sur doublon
force-command, la dernière gagne. Gravité : mineure — non exploitable sans la clé de
la CA (le blob entier reste signé) ; identique aux chemins monolithique et privsep (parseur
partagé). Fix : dans cert.rs::parse, rejeter doublon/hors-ordre (parité OpenSSH). Effet :
transverse (durcit mono + privsep d’un coup). Priorité : moyenne. Trouvé par la revue
adverse de #526 (O2).
D2 — ✅ FAIT (#541) — air-keystore : zeroize des tampons d’encodage porteurs du secret (F2)
Livré : #541. Les trois tampons du chemin d’écriture d’un magasin secret sont
désormais effacés : la mémoire de segment du Builder (nouvel allocateur capnp
store::ZeroizingAllocator, qui possède ses segments et les efface à son Drop), le Vec
de sérialisation (store::seal_secret_message) et — déjà le cas — le tampon d’artefact final.
Appliqué aux deux magasins secrets (hostkeys et ca_key). La politique de
dimensionnement de l’allocateur est calquée sur celle du HeapAllocator par défaut de
capnp ⇒ même segmentation, mêmes octets de sortie ; un test le verrouille octet à octet.
Modification couche 1 ADDITIVE et behavior-preserving : aucune API publique, aucun octet
d’artefact changé — aucun re-sceau (descellement additif ouvert pendant le chantier
air-sshd, directive BDFL).
Sur le chemin d’écriture (encode_artifact/save) de hostkeys et ca_key, la graine
privée transitait par la mémoire de segment du Builder Cap’n Proto et le Vec rendu par
write_message_to_words, droppés sans effacement (seul le tampon d’artefact final était
zeroizeé). Gravité : observation — pré-existant (patron scellé de hostkeys, calqué
par ca_key conformément à ADR-140) ; processus courts. Trouvé par la revue adverse de
#528 (F2).
D3 — ✅ FAIT (#541) — Fuzz des magasins scellés : franchir l’enveloppe FNV (F3)
Livré : #541. Nouvelle cible fuzz_air_keystore_sealed_body : l’entrée du fuzzer est le
corps Cap’n Proto, autour duquel le harnais recompose une enveloppe AIRCFGv1 valide
(magie + FNV-1a-64 recalculé) avant d’appeler les décodeurs. Le décodage capnp — table de
segments, pointeurs, listes, graine, périmètre, blobs — est donc réellement exploré.
Transverse : les cinq magasins enveloppés (ca_key, hostkeys, ca, hosts,
revocation), avec round-trip encode ∘ decode sur tout artefact accepté — ce qui exerce du
même coup le chemin d’écriture durci par D2.
Le FNV est réimplémenté dans le harnais : seal_artifact reste pub(crate) (un harnais de
fuzz ne justifie pas d’élargir la surface publique d’un crate de couche 1).
Les cibles de fuzz des magasins scellés (fuzz_air_keystore_*) commençaient par
verify_envelope (magie 8 o + FNV-1a) ; un fuzzer d’octets bruts a ~2⁻⁶⁴ de franchir le
checksum, donc le décodage capnp sous enveloppe valide n’était pas réellement exploré
(les branches de rejet restaient couvertes par les tests unitaires — la couverture 100 % tenait).
Trouvé par la revue adverse de #528 (F3).
D4 — Principal utilisateur vide : clos à l’émission, note de vérification
Un cert utilisateur à valid_principals vide est « valable pour tout compte » (sémantique
OpenSSH conservée à la vérification, ADR-139). Clos côté émission : la CLI d’Air
refuse d’émettre un cert user sans principal (ADR-140 §D5) — Air ne produit jamais ce cas.
Reste optionnel (basse priorité) : une politique de vérification « principal explicite
obligatoire pour les certs user » (durcissement transverse mono+privsep, divergerait du
défaut OpenSSH — donc réglage, pas défaut). Trouvé par la revue adverse de #526 (O3).
D5 — ✅ FAIT (#539) — air-keystore cert verify --ca <*.pub> : signaler que le périmètre n’est pas vérifié
Livré : #539. verify n’énonce plus de contrôle tautologique : avertissement « CA fournie
en .pub : périmètre non vérifié indépendamment » (texte + JSON), et --principal exigé
pour un cert utilisateur (l’inspection sans principal reste offerte par cert inspect).
Un .pub de CA ne porte aucune métadonnée de périmètre ; sur ce chemin, le contrôle de scope
devient tautologique (le périmètre requis = le type du cert vérifié). Le refus de périmètre
réel n’est exerçable qu’avec un trusted_ca.aircfg scellé (dont les entrées portent leur
scope). Fix : que le rapport de verify --ca <.pub> énonce « CA fournie en .pub :
périmètre non vérifié indépendamment ». Priorité : basse. Revue #532. Idem : verify d’un
cert utilisateur sans --principal teste le 1er principal du cert lui-même (tautologique) —
un ACCEPTÉ peut se lire comme « ce compte est autorisé » ; envisager d’exiger --principal pour un
cert user, ou de l’étiqueter plus fort. (Recoupe [D4].)
D6 — ✅ FAIT (#542) — Gate admin : durcir --admin-base (redirection de la base de credentials)
Livré : #542. --admin-base n’existe plus dans la surface CLI : ni drapeau
d’argv, ni argument nommé d’un payload --json-input (les deux le refusent désormais comme
argument inconnu, sur les lectures comme sur les mutations). En production, admin_base
est une fonction #[cfg(not(test))] qui rend le chemin système en dur —
air_admin_gate::DEFAULT_BASE_DIR (/etc/air), là où air-account gère shadow.aircfg
(ADR-100). L’injection de test passe par un champ #[cfg(test)] de CliEnvironment :
il n’est pas compilé dans le binaire livré, donc la redirection n’est pas seulement
interdite, elle est inexprimable. Deux tests l’épinglent : l’un pose deux bases actives
(la « système » injectée, et un piège dont l’appelant connaît le secret) et vérifie que ni
l’argv ni le payload ne font du piège le juge — le secret du piège est refusé, seul celui
de root dans la base système autorise ; l’autre fixe le chemin système (/etc/air,
shadow.aircfg) et vérifie qu’aucun texte d’usage ne propose plus le drapeau. Effet de bord
utile : les tests ne dépendent plus, en silence, du /etc/air de la machine qui les exécute.
Changement couche 2 seulement, sans re-sceau.
air-keystore accepte --admin-base depuis l’argv non fiable : le gate ADR-101 vérifie alors le
mot de passe contre une base choisie par l’appelant (anti-motif pour une barrière d’autorisation).
Non exploitable en config correcte : la CLI n’est pas privilégiée ⇒ l’écriture d’un magasin
système 0600 root échoue de toute façon sur les permissions FS (contrôle primaire) ; et
ADR-101 §4 concède que root contourne (défense en profondeur). Ne mord que si les perms FS sont
déjà cassées. Fix : rendre --admin-base test-only / caché (le gate vérifie toujours
contre le shadow.aircfg système en production). Priorité : moyenne (touche l’intégrité du gate).
Revue #534 (F1).
D7 — ✅ FAIT (#541) — Gate admin : zeroize du clair du mot de passe côté CLI
Livré : #541. Toutes les copies amont du clair sont désormais tenues en Zeroizing dans
air-keystore-cli : le payload --json-input lu sur stdin, DecodedCommand.admin_password,
CommandContext.admin_password, et le flux stdin du mode texte. Une quatrième copie,
non nommée par le constat d’origine, est traitée aussi : celle que porte l’arbre JSON parsé —
take_admin_password prélève le secret (copie en Zeroizing) puis efface sur place la
chaîne de l’arbre, au lieu de la lire et de la laisser vivre jusqu’à la fin du décodage. La CLI
tire donc zeroize en direct, exception structurante PARTAGÉE déjà consignée
(docs/EXCEPTIONS.md) — aucune nouvelle exception.
Le clair du mot de passe admin n’était en Zeroizing que dans air-admin-gate::authorize ; les
copies amont côté CLI (payload JSON de read_stdin, DecodedCommand.admin_password,
CommandContext.admin_password) restaient en mémoire non effacées le temps de la commande.
Pas de fuite en sortie ni de contournement — écart de défense en profondeur vs l’intention
Zeroizing d’ADR-101. Revue #534 (F2).
D8 — ✅ CONFIRMÉ (#542) — ca create / cert issue-* : non-gate délibéré
Confirmé : #542 — choix conscient, comportement inchangé, désormais écrit dans le
code (note au-dessus de run_ca, renvoi sur run_cert). La ligne de partage : le gate
d’ADR-101 protège les magasins de confiance de la machine (revoke, host-trust,
known-hosts), pas la création d’un artefact à chemin nommé que l’opérateur désigne
lui-même (--store, --out) — exactement comme ssh-keygen écrit la clé qu’on lui nomme.
Ce qui protège un chemin système sur ces deux verbes, ce sont les permissions FS : la
CLI n’est pas privilégiée, et ca create --store /etc/air/… échoue sur EACCES sans qu’aucun
verdict de gate n’ait à intervenir. Les gater ajouterait un mot de passe à la génération d’une
clé CA dans le répertoire personnel de l’opérateur — coût réel, gain nul. (Lecture d’ADR-115
§3 : « les mutations de la CA » y désigne le magasin de confiance système, pas le fichier
que ca create crée à l’endroit demandé. Si la clé CA rejoignait un jour un magasin système
partagé, la question se rouvrirait — décision BDFL à ce moment-là, pas avant.)
air-keystore ca create (création/écrasement --force d’une clé CA privée) et cert issue-*
écrivent sans gate admin — création d’artefacts à chemin nommé par l’opérateur (façon
ssh-keygen), pas mutation d’un magasin de confiance système (les seuls gatés, ADR-115 §3).
Vraisemblablement délibéré ; à confirmer comme choix conscient d’ADR-115 (et, si un jour la
clé CA rejoint un magasin système partagé — cf. idée parquée keystore — reconsidérer). Priorité :
basse (observation). Revue #534 (F3).
D9 — ✅ FAIT (#540) — cert.rs : round-trip byte-exact du champ reserved
air_ssh_proto::cert::parse jetait le champ reserved du certificat (_reserved) et
serialize_to_be_signed/encode le réécrivait vide. Pour un certificat dont le reserved
n’est pas vide, encode() n’était donc pas byte-exact avec le blob analysé — ce que la
doc du crate promet (« réencodage byte-exact = preuve que le sérialiseur correspond à OpenSSH »)
— et l’invariant de round-trip de la cible de fuzz (parse → encode → parse) pouvait
faux-crasher. Gravité : correction, pas sécurité — impact nul. La vérification de
signature porte sur les octets to_be_signed capturés depuis le blob d’origine (reserved
réel inclus), jamais sur une resérialisation : aucun certificat n’est accepté à tort, aucune
divergence de décision entre chemins. Défaut préexistant (antérieur à [D1]), trouvé par le
fuzz de #539. Fix (livré, #540) : conserver le reserved analysé et le ré-émettre tel
quel ; issue continue d’émettre un reserved vide (parité OpenSSH — le champ ne sert qu’à
préserver ce qui est analysé), et AirCertParams reste inchangé. Priorité : basse.
Hors périmètre de ce registre
- Le quota de signatures du monitor (oracle de signature pré-auth) a sa propre piste — ADR-133 + note de conception ; ne pas dupliquer ici.
- Les durcissements structurants (nouvelle décision d’architecture) passent par leur propre ADR, pas par ce registre (qui ne porte que des correctifs ciblés, sans changement de contrat).
Conséquences
Positives. Dette de sécurité visible, ratifiée et ordonnançable ; rien n’est perdu. Les items transverses (D1/D2/D3) durcissent mono et privsep en une passe.
Coûts. Plus aucun coût en attente : tous les correctifs du registre sont livrés.
D1/D5 par #539 (cert.rs refuse les options critiques et extensions mal formées ; verify
énonce ce qu’il n’a pas vérifié) ; D9 par #540 (round-trip byte-exact du champ
reserved) ; D2/D3/D7 par #541 (plus aucune graine privée dans un tampon d’encodage non
effacé, le fuzz des magasins scellés franchit l’enveloppe, le clair du mot de passe admin ne
traîne plus en mémoire côté CLI) ; D6 par #542 (le gate juge contre la base système,
chemin fixe et non redirigeable) ; D8 confirmé par #542 (non-gate délibéré de
ca create / cert issue-*, écrit dans le code). Ne subsiste que D4, qui n’est pas une
dette mais une option : son volet défaut est clos à l’émission, et son volet restant
serait une divergence assumée d’avec OpenSSH — donc un réglage, à ouvrir sur décision, pas un
correctif en retard. Aucun de ces coûts n’aura été un chemin d’attaque exploitable à
distance.
Alternatives rejetées
Aucune alternative n’a été consignée lors de l’instruction.
Suite
Registre traité : D1/D5 (#539), D9 (#540), D2/D3/D7 (#541 — groupe « hygiène des secrets keystore »), D6 + D8 (#542 — groupe « intégrité du gate »). Reste : rien à corriger. Seul D4 demeure ouvert en tant qu’option (politique de vérification « principal explicite obligatoire », divergence d’avec OpenSSH) — à ouvrir sur décision, pas un item en retard. Ce registre s’enrichira des futurs fast-follow de sécurité de même nature (correctifs ciblés, sans changement de contrat).