ADR-140 — Conception : émission de certificats + clé CA privée (descellement additif couche-1-v3.8) ; interop réel d’émission
Statut : Accepté (conception, 2026-07-30 ; directive BDFL 2026-07-30 « aller
jusqu’à la fin d’ADR-109 »). Mini-ADR de conception sur le modèle d’ADR-138/ADR-139.
Réalise les incréments Inc.4 (clé CA privée + orchestration d’émission,
valid_principals = [M]) et Inc.5 (interop réel ssh) d’ADR-109 §3/§4. RFC de
structure (ADR-015).
S’appuie sur ADR-109 (certificats SSH + CA), ADR-108 (air-keystore, cycle de vie
des clés, air-keysign), ADR-137 (identité machine M = empreinte host key),
ADR-134 (host key → keystore), [ADR-091] (sans-IO fuzzé), ADR-029 (nommage).
Catégorie : descellement ADDITIF couche 1 (air-keystore : module clé CA privée +
schéma) → re-sceau couche-1-v3.7 → couche-1-v3.8 (tag par le superviseur) +
orchestration d’émission couche 2 (air-sshd, évolue librement). Aucun retrait,
aucune signature modifiée, aucun comportement scellé changé (additif strict).
Contexte
L’émission de certificats est à moitié disponible : la primitive pure existe et est
testée — air_ssh_proto::cert::AirSshCertificate::issue(params, &AirSigningKey) avec
AirCertParams complet (User/Host, valid_principals libre). L’identité machine M
existe — air_sshd::host_identity::machine_identity(&[u8;32]) -> "SHA256:…" (ADR-137).
La clé d’hôte privée est gérée en couche 1 (air_keystore::hostkeys, magasin scellé
0600).
Le trou : il n’existe aucune clé CA privée dans air-keystore (seulement le magasin
des CA publiques de confiance, trusted_ca.aircfg), ni orchestration chargeant une
clé CA pour appeler issue, ni surface pour générer/gérer une CA et émettre. Sans quoi
M ne peut être émis dans un valid_principals, et l’interop réel (ADR-109 §Inc.5)
est intestable côté « Air émet ».
Décisions de conception
D1 — Clé CA privée en couche 1 : nouveau module air_keystore::ca_key
Nouveau module calqué sur hostkeys.rs : type AirCaKey (encapsule un AirSshKeyPair
Ed25519 + métadonnées key_id, scope: AirCaScope {User/Host/Any}, created_at),
schéma Cap’n Proto neuf ca_key.capnp, magasin scellé *.aircfg, load/save,
ZeroizeOnDrop. Additif (module + schéma neufs ; rien de scellé n’est touché) ⇒
re-sceau couche-1-v3.8. Le décodeur d’artefact parse un fichier externe ⇒ fuzz
obligatoire (doctrine couches 0/1).
D2 — Protection au repos = magasin scellé 0600, passphrase différée (uniforme)
La clé CA privée est protégée comme la clé d’hôte : StoreVisibility::Secret (0600) +
enveloppe scellée AIRCFGv1 (intégrité FNV, pas chiffrement) + zeroize des tampons. Le
chiffrement sous clé dérivée Argon2id n’existe nulle part dans air-keystore
(explicitement différé, ADR-108 §2) — l’exiger pour la seule CA imposerait d’implémenter
Argon2 d’abord (hors périmètre). Arbitrage (ADR-109 « à durcir ») : 0600 aujourd’hui,
uniforme ; quand Argon2 arrive (ADR-108 §2), il s’applique aussi à la CA. La mitigation
forte est structurelle (D4), pas le chiffrement au repos.
D3 — Orchestration d’émission = couche 2 (air-sshd)
La fonction qui charge la clé CA depuis le keystore (couche 1) et appelle issue
(primitive couche 2) vit en couche 2 (ADR-109 §3 : « l’émission dépend de proto et
du keystore ; jamais en couche 1 »). Elle utilise air_sshd::host_identity::machine_identity
(couche 2) pour poser valid_principals = [M] d’un cert hôte. Arbitrage (D7) : le
helper d’empreinte reste en air-sshd::host_identity (l’option « le descendre en
air-keystore » d’ADR-137 l.117 est écartée — l’orchestration est couche 2, elle y a
accès sans faire remonter une dépendance).
D4 — La clé CA privée est hors-ligne / admin, le démon ne la charge JAMAIS
Propriété de sécurité centrale : le démon face-réseau (air-sshd, privsep) ne détient
que la CA publique de confiance (trusted_ca.aircfg) ; il ne charge jamais la clé CA
privée. L’émission est une opération d’administration dans un processus court (la
CLI ci-dessous), invoquée hors du chemin d’authentification. La surface d’attaque réseau ne
touche donc jamais le matériel qui permet de forger des certificats — c’est la
mitigation primaire (bien plus que le chiffrement au repos), et elle réalise « CA hors-ligne
autant que possible » (ADR-109 Conséquences). Arbitrage (« qui signe », ADR-109 §3) :
signature in-process via AirSigningKey dans la CLI d’admin ; le service privilégié
air-keysign (ADR-108) reste un livrable ultérieur (non requis puisque la CA est
hors-ligne).
D5 — principal = M (hôte) / principals explicites (utilisateur)
- Cert d’hôte :
valid_principals = [ machine_identity(certified_key) ](ADR-137 §2) — l’émission recalculeMdepuis la clé certifiée (cohérence de soi par construction). - Cert d’utilisateur :
valid_principals = [ <noms d'utilisateur fournis par l'admin> ]. Émettre un cert utilisateur sans principal (valable pour tout compte) est refusé par la CLI (garde-fou : l’admin doit nommer au moins un principal ; la vérification garde la sémantique OpenSSH « vide = tous », mais l’émission d’Air ne produit jamais ce cas dangereux). Arbitrage de durcissement à l’émission (n’altère pas la vérification, cf. ADR-139 parité).
D6 — Surface CLI minimale et provisoire (subsumée par ADR-115)
ADR-109 reporte la CLI complète à ADR-115 (air-keystore CLI). Inc.4/5 exigent un
minimum. Arbitrage : ajouter des sous-commandes à air-sshd (cohérent avec l’existant
config/authorized-keys/migrate-host-key-store, parsing manuel), explicitement
provisoires — ADR-115 les relogera dans la CLI air-keystore :
air-sshd ca init [--scope host|user|any] [--store <chemin>]— génère une clé CA Ed25519, la scelle0600;air-sshd ca show [--store <chemin>]— clé publique + empreinte (jamais la privée) ;air-sshd cert issue-host --ca <store> --host-key <hostkeys.aircfg|blob> [--validity …]— émet un cert d’hôteprincipal = M;air-sshd cert issue-user --ca <store> --key <pub> --principals a,b [--validity …] [--force-command …] [--permit …]— émet un cert utilisateur.
Les certs sont écrits sur disque (0644, ce sont des objets publics) au format wire
OpenSSH (AirSshCertificate::encode).
Note de statut (2026-07-30) — la dette de D6 est PAYÉE. Ces quatre verbes ont été retirés d’
air-sshdpar l’incrément 4 d’ADR-115, et sont relogés dans la CLIair-keystore:air-keystore ca create|showetair-keystore cert issue-host|issue-user.air-sshdconserveconfig,authorized-keys,migrate-host-key-storeetinit-host-key(ce dernier appelé par lepostinst, §D8), et ne charge plus jamais de clé CA privée, sous aucun verbe — ce qui rend §D4 vrai par absence de chemin, et non plus par discipline d’appel. La décision de D6 est inchangée : elle prévoyait ce relogement, il a eu lieu.
D7 — Interop réel (Inc.5), sens Air émet → OpenSSH vérifie
Tests d’interop réutilisant le harness existant (ready_or_skip, port éphémère, gating
AIR_*_INTEROP_REQUIRED) :
- Cert utilisateur : Air (
ca init+cert issue-user) émet un cert ; unsshdOpenSSH configuréTrustedUserCAKeys = <CA pub>acceptessh -i <cert Air>→ marqueur. Prouve que l’émission d’Air est wire-compatible OpenSSH. - Cert d’hôte
principal = M: Air émet un cert d’hôteprincipal = M; un client OpenSSH le vérifie via@cert-authority+HostkeyAlias SHA256:<M>(ADR-137 §5). Valide la réserve d’ADR-137 §5 (matching principal↔HostkeyAliasselon version OpenSSH). Si une particularité de version bloque, le résultat est documenté (testready_or_skip), pas masqué.
Note de statut (2026-07-30). Ces tests ont suivi l’outil qui émet : ils vivent désormais dans
crates/air-keystore-cli/tests/cert_issue_interop.rs(même montage, mêmes assertions, mêmesshdOpenSSH réel), puisque les verbes qu’ils pilotent sont relogés (cf. la note de §D6). L’incrément 4 d’ADR-115 leur en a ajouté un troisième : le même montage que le cert d’hôte, mais aveccert issue-host --openssh-hostkey-alias-compat(ADR-115 §6), qui réussit — la preuve positive que le pliage de casse duHostkeyAliasétait la seule cause du refus documenté ci-dessus.
D8 — Postinst d’identité machine (ADR-137 §4) — packaging
Le postinst du .deb initialise le keystore et génère la host key si absente
(AirSshKeyPair::generate → air_keystore::hostkeys::save, 0600), idempotent (host key
présente ⇒ jamais régénérée). Établit M à l’installation. C’est de l’ADR-137 §4 déjà
spécifié (aucune décision neuve) — réalisé dans xtask/src/deb.rs (écriture d’un
DEBIAN/postinst 0755), en réutilisant le motif de host_key_migration.rs.
D9 — Layering & sceau
- Couche 1 additif :
air_keystore::ca_key+ schémaca_key.capnp⇒ re-sceaucouche-1-v3.8(tag signé par le superviseur après CI verte). Faces historiques d’air-keystoreinchangées. - Couche 2 : orchestration d’émission + CLI + interop dans
air-sshd(évolue librement). - Fix propre (
store.rs) : désambiguïser la docca(CA publiques de confiance,0644) du nouveauca_key(CA privée,0600 Secret).
Plan des sous-étapes
- 4.a couche 1 :
ca_key(type + schémaca_key.capnp+ load/save0600+ zeroize) + fuzz du décodeur d’artefact. Tests 100 % (couche 1). - 4.b couche 2 : orchestration d’émission (
issue-hostprincipal=M/issue-user), chargement clé CA + host key, appelissue. Tests. - 4.c CLI provisoire
air-sshd ca …/cert …(D6). - 5.a interop réel (D7) : user cert (OpenSSH
TrustedUserCAKeys) + host cert (HostkeyAlias). - post.a postinst idempotent (D8) dans
xtask/deb.rs.
Chaque sous-étape : branche + PR verte + re-vérification (barrière, couvrable-vide, fuzz).
Ordre : 4.a → 4.b → 4.c → 5.a → post.a (post.a peut précéder si utile au déploiement).
Alternatives rejetées
- Clé CA privée en couche 2 : le matériel de clé + magasin scellé relève d’
air-keystore(couche 1) — cohérent avec la host key ; l’orchestration seule est couche 2. - Exiger Argon2/passphrase pour la CA maintenant : Argon2 n’existe nulle part (ADR-108 §2 différé) ; l’imposer ici sortirait du périmètre. Mitigation structurelle (D4) retenue ; durcissement chiffrement quand ADR-108 §2 atterrira.
air-keysign(service privilégié) pour émettre : la CA est hors-ligne (D4) ; un service privilégié serait sur-dimensionné. Signature in-process dans la CLI d’admin.- CLI d’émission = crate/bin dédié tout de suite : doublonnerait ADR-115 ; sous-commandes
air-sshdprovisoires, relogées par ADR-115. - Descendre
machine_identityen couche 1 : inutile (orchestration couche 2) ; éviterait une frontière propre. Le helper reste enair-sshd.
Conséquences
Positives. Air peut émettre des certs d’hôte (principal = M) et d’utilisateur,
wire-compatibles OpenSSH (prouvé Inc.5) ; l’identité machine devient émissible, pas
seulement vérifiable ; le postinst établit M à l’install. La clé CA privée reste hors du
démon — surface réseau ne pouvant jamais forger un cert.
Coûts / risques. Protection de la CA au repos = 0600 (pas de chiffrement — à durcir,
ADR-108 §2) ; atténué par D4 (hors-ligne, hors démon). CLI provisoire (dette assumée,
relogée par ADR-115). Compromission de la clé CA = forge de certs (inhérent au modèle CA ;
d’où D4 + « hors-ligne autant que possible »).
Emplacements d’implémentation
| Morceau | Où | Sous-étape |
|---|---|---|
AirCaKey + ca_key.capnp + load/save 0600 + fuzz | air-keystore/src/ca_key.rs, air-keystore-schema/schema/ca_key.capnp, fuzz/ | 4.a |
Orchestration d’émission (principal = M) | air-sshd/src/ (module d’émission) | 4.b |
CLI provisoire ca/cert | air-sshd/src/bin/air-sshd.rs (+ module) | 4.c |
| Interop réel Air→OpenSSH | air-sshd/tests/ | 5.a |
| Postinst idempotent | xtask/src/deb.rs | post.a |
Désambiguïsation doc ca vs ca_key | air-keystore/src/store.rs | 4.a |
Suite
- Après Inc.4/Inc.5/postinst : ADR-109 est clos. Déploiement
.debversionné sur carbon/speedy/raspi-srv-2 (directive BDFL 2026-07-30). Puis vague 2 ADR-115 (CLIair-keystorecomplète, qui subsume la CLI provisoire d’ici). - Enregistrement :
docs/SUMMARY.md,docs/adrs/registre-adrs-fr.md, noteetat-avancement.md.