ADR-139 — Conception : authentification par certificat utilisateur sous privsep — le monitor vérifie, via un message RPC dédié
Statut : Accepté (conception, 2026-07-30). Le quoi (« sous privsep, le monitor
vérifie le certificat ») a été tranché par le BDFL le 2026-07-29 (Option A ; voir
ADR-109 plan d’incréments). Cet ADR fixe le comment avant tout code (mini-ADR
de conception, sur le modèle d’ADR-124/ADR-129). RFC de structure (ADR-015).
Raffine le §D5 d’ADR-124 (message AUTHORIZE) et l’étend à la voie certificat.
S’appuie sur ADR-124 (canal RPC pré-auth monitor↔enfant, codec sans-IO fuzzé),
ADR-122 (topologie privsep), ADR-109 (certificats SSH + CA), ADR-128 (options
d’authorized_keys, adresse du pair côté monitor), ADR-138 (révocation par serial +
KRL, couche-1 v3.7), ADR-112 (air-sandbox), ADR-029 (nommage).
Catégorie : conception d’architecture couche 2 (air-sshd). Aucun code ici :
topologie du message, découpage, obligations de fuzz. Aucun descellement, aucun
re-sceau (voir §D10).
Contexte
La vérification d’un certificat utilisateur SSH est déjà entièrement implémentée
et fuzzée dans le chemin monolithique (mono-process) d’air-sshd :
- codec + crypto :
air-ssh-proto::cert(AirSshCertificate::parse/verify_signature,verify_certificateorchestrant type → signature CA → CA de confiance → validité → principal → options critiques → révocation) ; - confiance CA :
air-keystore::ca(AirTrustedCa,is_trusted_ca, périmètreUser) ; - révocation :
air-keystore::revocation(AirRevocationStore) + la règle uniqueair-sshd::cert_revocation::certificate_is_revoked(ADR-138) ; - câblage serveur :
air-sshd::authorize::CertAwareAuthorizer(implémenteauthorize_cert), routé depuisuserauth::authorize_publickeydès que l’algorithme vautssh-ed25519-cert-v01@openssh.com.
Le trou est le privsep. Sous privsep, le seam d’autorisation de l’enfant pré-auth
est MonitorAuthorizedKeys, qui n’implémente que is_authorized (émet AUTHORIZE au
monitor pour une clé nue) et hérite du défaut authorize_cert = false
(fail-closed). Conséquences factuelles aujourd’hui :
- le codec RPC (
monitor::proto) n’a aucun message pour un certificat ; - le monitor (
MonitorAuthorizer) ne charge ni CA de confiance ni magasin de révocation — il ne consulte qu’authorized_keys; - le démon refuse de démarrer si
trusted_caet privsep sont configurés ensemble (bin/air-sshd.rs::resolve_privsep: « l’authentification par certificat n’est pas encore portée par le privsep (elle serait refusée sans le dire) »).
Directive BDFL (2026-07-29), Option A — RETENUE. « Sous privsep, c’est le MONITOR qui vérifie le certificat. On étend le RPC pré-auth pour que l’enfant confiné transmette le cert au monitor, qui exécute la vérification complète et rend ALLOW/DENY muet. L’enfant ne décide JAMAIS la confiance. » (Option B « l’enfant vérifie » = rejetée : un enfant pré-auth compromis déciderait de la confiance — cela vide le privsep de son sens.)
Décisions de conception
D1 — Répartition enfant (crypto) / monitor (confiance) — invariant du privsep
Inchangée par rapport à la voie clé nue (ADR-122/ADR-124) :
- L’enfant pré-auth confiné fait la crypto pure : il vérifie la signature du
client sur la session avec la clé certifiée (
verify_publickey_signature, branche cert déjà présente — preuve de possession de la clé privée). Il ne fait aucune décision de confiance. - Le monitor privilégié fait toute la décision de confiance : parse du cert,
signature de la CA, CA de confiance, validité, principal, options critiques, révocation
— puis traduit en
ALLOWcontraint (commande forcée + permissions) ouDENYmuet.
D2 — Un message RPC dédié AUTHORIZE_CERT (pas de surcharge d’AUTHORIZE)
On ajoute un message requête au codec monitor::proto (tag requête 4) plutôt que
de faire passer un blob de cert dans le champ public_key d’AuthorizeRequest :
AuthorizeCertRequest {
username: Vec<u8>, // le login demandé par le client (octets, pas de garantie UTF-8)
certificate: Vec<u8>, // le blob de certificat COMPLET et BRUT (ssh-ed25519-cert-v01)
}
Les réponses sont réutilisées telles quelles : AuthorizeAllow { forced_command, permissions } (tag 3) / AuthorizeDeny (tag 4, muet). Le message ne porte pas :
- d’algorithme — la voie est implicitement « certificat » ; le monitor valide
lui-même le type (
ssh-ed25519-cert-v01seul supporté) ; - de
now— le monitor s’horloge lui-même (D4) ; - d’adresse du pair — le monitor la détient déjà (
MonitorAuthorizer.peer_address, héritée du listener au fork, ADR-128/V2.5). L’enfant ne la fournit jamais.
Justification du message dédié : surface de décodage et de fuzz distincte ; la
décision « certificat » est une surface de confiance propre ; le message AUTHORIZE
(clé nue) reste étroit. C’est l’option sanctionnée par ADR-124 (« message dédié, ou
certificats hors privsep »). Bornes du codec inchangées (MAX_FIELD_LEN = 64 KiB,
MAX_MESSAGE_LEN = 256 KiB) ; un cert Ed25519 tient très en deçà.
D3 — Le RPC transporte le blob brut, jamais des champs parsés — le monitor re-parse
Le message porte le certificat brut, pas des champs extraits (clé certifiée, principals,
options…). Le monitor re-parse le blob lui-même (AirSshCertificate::parse, schema-first,
borné) et ne fait confiance qu’à sa propre analyse. Propriété de sécurité capitale :
un enfant pré-auth compromis ne peut pas fabriquer une confiance en fournissant des
champs parsés menteurs — il n’y a aucun champ parsé dans le fil. (Posture ADR-124 §D3 :
le monitor traite l’enfant comme hostile ; parse, don't validate, bornes strictes.)
D4 — Le monitor s’horloge lui-même pour la validité
La fenêtre valid_after ≤ now < valid_before est évaluée avec l’horloge du monitor
(lecture temps réel dans le processus de confiance), jamais un now transmis par
l’enfant. Sans quoi un enfant compromis rejouerait un certificat expiré en falsifiant
l’horloge (oracle temporel). Le now n’apparaît pas dans le message RPC (D2).
D5 — Décision du monitor = verify_certificate + traduction options→contrainte
À réception d’AUTHORIZE_CERT, le monitor :
- parse le blob (D3) ; type ≠
User⇒DENY; - exécute
verify_certificateavec uneAirCertPolicymontée côté monitor :now= horloge monitor (D4) ;required_kind = User;principal = username;is_ca_trusted→is_trusted_ca(scope = User)sur les CA chargées (D6) ;is_revoked→cert_revocation::certificate_is_revokedsur le magasin de révocation (D6) ;known_critical_options = { force-command, source-address }; - verdict :
- tout verdict ≠
Accepted(WrongKind/BadSignature/UntrustedCa/Expired/PrincipalNotAllowed/UnknownCriticalOption/Revoked) ⇒AuthorizeDeny(muet), verdict consigné en interne pour le journal (motif jamais renvoyé à l’enfant, motifauthorize_with_reasonexistant) ; Accepted⇒ le monitor traduit le certificat enAuthorizeConstraintpuis répondAuthorizeAllow:- extensions →
SessionPermissions: une permission est accordée si et seulement si l’extension correspondante est présente (permit-pty,permit-port-forwarding,permit-agent-forwarding,permit-X11-forwarding,permit-user-rc). Absence = refus de la permission (fail-closed, parité OpenSSH) ; force-command(option critique) →forced_command;source-address(option critique) → honorée par le monitor contrepeer_address, en réutilisant le matcher d’adresse defrom=(ADR-128/V2.5). Pair inconnu etsource-addressprésente ⇒DENY(fail-closed, commePeerAddressUnknown/SourceNotAllowed).
- extensions →
- tout verdict ≠
Parité stricte avec le monolithique. La sémantique est identique à celle de
CertAwareAuthorizer déjà livrée (y compris : valid_principals vide ⇒ valide pour tout
utilisateur — comportement OpenSSH documenté ADR-109 ; un durcissement « principal
explicite obligatoire » serait transverse aux deux chemins, hors périmètre Inc.2).
Le privsep relocalise la décision dans le monitor, il ne change pas la politique.
D6 — Sources de confiance : CA + révocation chargées au démarrage, héritées COW
trusted_ca.aircfg (CA de confiance) et revoked.aircfg (révocation) sont des données
publiques (clés publiques de CA, serials/empreintes révoqués) — pas un secret comme
la clé d’hôte privée. Elles sont donc chargées une fois au démarrage du listener
et héritées par le monitor en COW, exactement comme le fait le chemin monolithique
(CertAwareAuthorizer construit ses trusted_cas/revocation au démarrage).
- Asymétrie assumée vs la clé d’hôte privée (ADR-124 §D4, amendement) : la clé privée doit être chargée dans le corps du monitor, après le fork (jamais dupliquée dans l’enfant). Les stores publics n’ont pas cette contrainte — les hériter COW est sûr (rien de secret) et évite une I/O par connexion sur le chemin privilégié.
- Rafraîchissement = redémarrage /
SIGHUP(rechargement du listener) — hors périmètre Inc.2, cohérent avec le monolithique. (Re-lecture per-connexion de la révocation = rejetée : elle divergerait de la fraîcheur du monolithique et ajouterait de l’I/O sur le chemin privilégié chaud ; un durcissement « reload » éventuel serait transverse.)
D7 — Côté enfant : authorize_cert délègue (retrait du défaut false)
MonitorAuthorizedKeys::authorize_cert est implémenté : après la vérif crypto de la
signature client avec la clé certifiée (D1, déjà présente en amont dans userauth),
l’enfant émet AUTHORIZE_CERT { username, certificate = blob brut } et applique la réponse
(ALLOW contraint / DENY). Le défaut fail-closed = false (docstring authorize.rs
« à trancher quand le démon câblera le privsep ») est remplacé par cette délégation.
D8 — Levée du garde de démarrage resolve_privsep
Le garde bin/air-sshd.rs::resolve_privsep refusant privsep + trusted_ca est levé :
sous privsep, resolve_privsep charge les CA de confiance + le magasin de révocation
et les passe à la politique du monitor (comme resolve_legacy le fait déjà pour
CertAwareAuthorizer). Les deux autres gardes voisins (host-key-store absent,
authorization = deny) sont conservés.
D9 — Obligations de fuzz
- Étendre
fuzz/fuzz_targets/fuzz_air_sshd_monitor_proto.rs: decode deAuthorizeCertRequest+ round-trip stable (le nouveau tag4entre dans l’espace fuzzé). - Cible dédiée « le monitor décide un cert » : blob de certificat arbitraire +
ensemble de CA de confiance arbitraire + magasin de révocation arbitraire → la
décision du monitor ne panique jamais et est fail-closed (tout ce qui n’est pas
un
Acceptedcomplet ⇒DENY). (verify_certificateet le codeccertsont déjà fuzzés —fuzz_air_ssh_cert.rs; on fuzze ici le point de jonction monitor.)
D10 — Layering : couche 2 pure, aucun descellement, aucun re-sceau
Toutes les primitives de confiance existent déjà : verify_certificate (couche 2,
air-ssh-proto), is_trusted_ca/AirTrustedCa/AirRevocationStore/load (couche 1,
air-keystore, déjà scellées au re-sceau couche-1 v3.7 avec ADR-138),
SessionPermissions/AuthorizeConstraint (couche 2, air-sshd). Inc.2 ne requiert
aucune API couche 1 nouvelle : c’est exclusivement du câblage couche 2
(air-sshd : monitor::proto, monitor::service, monitor::authorize, preauth,
bin/air-sshd.rs). Aucun descellement de couche 1, aucun re-sceau (contraste avec
ADR-138, qui, lui, ajoutait la révocation en couche 1).
Plan de l’incrément (Inc.2)
Chaque sous-étape = commits sur la branche de l’Inc.2, verte + re-vérifiée (barrière,
couverture exception-aware couvrable-vide, fuzz étendu). Couverture couche 2 au
plancher agrégé.
- 2.a — proto :
AuthorizeCertRequest(tag4) dansmonitor::proto(encode/decode bornés,parse, don't validate) + extension du fuzz (D9). - 2.b — monitor : politique de décision cert dans
monitor::authorize— le monitor portetrusted_cas/revocation/known_critical_options, exécuteverify_certificate(D5), traduitAccepted→AuthorizeConstraint(extensions → permissions ;force-command→forced_command;source-address→ matcherfrom=). BrasAUTHORIZE_CERTdansmonitor::service(MonitorPolicy). Tests unitaires + KAT (cert valide → ALLOW contraint ; CA inconnue/expiré/révoqué/mauvais principal → DENY). - 2.c — enfant :
MonitorAuthorizedKeys::authorize_certdélègue (D7). - 2.d — démon : levée du garde
resolve_privsep+ câblage CA/révocation sous privsep (D8). - 2.e — bout-en-bout : le mono est déjà couvert ; ajouter un test privilégié
privsep (root, enfant forké, exception
CHILD-EXITdocumentée) :ssh -i <cert>accepté sous privsep, cert révoqué/expiré refusé muet.
Alternatives rejetées
- Surcharger
AUTHORIZE(blob de cert danspublic_key, algo = cert) : mélange deux surfaces de décision et de fuzz. Message dédié plus net et sanctionné (ADR-124). - Transporter des champs de cert PARSÉS dans le RPC : un enfant hostile ferait passer une confiance (principals/CA/options falsifiés). Le fil porte le blob brut, le monitor re-parse (D3).
nowfourni par l’enfant : oracle temporel (rejeu d’un cert expiré). Le monitor s’horloge (D4).- Option B — l’enfant vérifie le certificat : rejetée (BDFL) — un enfant pré-auth compromis déciderait la confiance ; le privsep ne protégerait plus rien.
- Re-lecture per-connexion de la révocation : divergence de fraîcheur vs le monolithique
- I/O sur chemin privilégié chaud. Chargement au démarrage + héritage COW (D6) ; rafraîchissement par reload (transverse, hors périmètre).
Conséquences
Positives. Le privsep atteint la parité fonctionnelle avec le monolithique pour
les certificats utilisateur ; toute la confiance (CA, révocation, validité,
principals, options) reste entièrement dans le processus privilégié ; l’enfant confiné
ne fait que la crypto + relayer un blob brut. Le garde de démarrage tombe :
trusted_ca + privsep devient un mode supporté. Couche 2 pure ⇒ pas de dette de
sceau.
Coûts / risques. Un aller-retour RPC + une re-parse du cert côté monitor (assumé, Principe 5 : sécurité d’abord). La fraîcheur de révocation est celle du dernier chargement (reload/redémarrage) — identique au monolithique. La confiance repose sur la CA (compromission CA ⇒ toute machine/utilisateur signé autorisé — inhérent au modèle CA, hors périmètre). La propriété « signer/autoriser pendant une compromission live de l’enfant » reste bornée par le confinement fort + le caractère éphémère de l’enfant (ADR-124 note oracle) — le monitor décide, mais l’enfant a déjà prouvé la possession de la clé certifiée ; il ne peut pas faire autoriser un autre utilisateur que celui pour lequel il détient la clé.
Emplacements d’implémentation
| Morceau | Où | Sous-étape |
|---|---|---|
Message AuthorizeCertRequest (tag 4) + fuzz | air-sshd/src/monitor/proto.rs, fuzz/…monitor_proto.rs | 2.a |
| Politique de décision cert (verify + traduction) | air-sshd/src/monitor/authorize.rs | 2.b |
Bras AUTHORIZE_CERT de la boucle | air-sshd/src/monitor/service.rs | 2.b |
authorize_cert délègue (enfant) | air-sshd/src/monitor/authorize.rs (MonitorAuthorizedKeys) | 2.c |
| Levée du garde + câblage CA/révoc privsep | air-sshd/src/bin/air-sshd.rs (resolve_privsep) | 2.d |
| Test bout-en-bout privsep | air-sshd (tests privilégiés) | 2.e |
(réutilisés tels quels) verify_certificate, is_trusted_ca, AirRevocationStore, certificate_is_revoked | air-ssh-proto, air-keystore, air-sshd/cert_revocation.rs | — |
Suite
- Implémenter Inc.2 (branche + PR verte + fuzz étendu), puis reprendre la séquence
ADR-109 : Inc.4 (clé CA privée + émission
valid_principals), Inc.5 (interop réelssh -i cert). - Enregistrement :
docs/SUMMARY.md,docs/adrs/registre-adrs-fr.md, notedocs/etat-avancement.md.