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-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 capnpmê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é : observationpré-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 pisteADR-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).