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-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.7couche-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 recalcule M depuis 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 provisoiresADR-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 scelle 0600 ;
  • 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ôte principal = 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-sshd par l’incrément 4 d’ADR-115, et sont relogés dans la CLI air-keystore : air-keystore ca create|show et air-keystore cert issue-host|issue-user. air-sshd conserve config, authorized-keys, migrate-host-key-store et init-host-key (ce dernier appelé par le postinst, §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 ; un sshd OpenSSH configuré TrustedUserCAKeys = <CA pub> accepte ssh -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ôte principal = 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↔HostkeyAlias selon version OpenSSH). Si une particularité de version bloque, le résultat est documenté (test ready_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ême sshd OpenSSH 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 avec cert issue-host --openssh-hostkey-alias-compat (ADR-115 §6), qui réussit — la preuve positive que le pliage de casse du HostkeyAlias é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::generateair_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éma ca_key.capnp ⇒ re-sceau couche-1-v3.8 (tag signé par le superviseur après CI verte). Faces historiques d’air-keystore inchangées.
  • Couche 2 : orchestration d’émission + CLI + interop dans air-sshd (évolue librement).
  • Fix propre (store.rs) : désambiguïser la doc ca (CA publiques de confiance, 0644) du nouveau ca_key (CA privée, 0600 Secret).

Plan des sous-étapes

  • 4.a couche 1 : ca_key (type + schéma ca_key.capnp + load/save 0600 + zeroize) + fuzz du décodeur d’artefact. Tests 100 % (couche 1).
  • 4.b couche 2 : orchestration d’émission (issue-host principal=M / issue-user), chargement clé CA + host key, appel issue. 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-sshd provisoires, relogées par ADR-115.
  • Descendre machine_identity en couche 1 : inutile (orchestration couche 2) ; éviterait une frontière propre. Le helper reste en air-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

MorceauSous-étape
AirCaKey + ca_key.capnp + load/save 0600 + fuzzair-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/certair-sshd/src/bin/air-sshd.rs (+ module)4.c
Interop réel Air→OpenSSHair-sshd/tests/5.a
Postinst idempotentxtask/src/deb.rspost.a
Désambiguïsation doc ca vs ca_keyair-keystore/src/store.rs4.a

Suite

  • Après Inc.4/Inc.5/postinst : ADR-109 est clos. Déploiement .deb versionné sur carbon/speedy/raspi-srv-2 (directive BDFL 2026-07-30). Puis vague 2 ADR-115 (CLI air-keystore complète, qui subsume la CLI provisoire d’ici).
  • Enregistrement : docs/SUMMARY.md, docs/adrs/registre-adrs-fr.md, note etat-avancement.md.