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-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_certificate orchestrant type → signature CA → CA de confiance → validité → principal → options critiques → révocation) ;
  • confiance CA : air-keystore::ca (AirTrustedCa, is_trusted_ca, périmètre User) ;
  • révocation : air-keystore::revocation (AirRevocationStore) + la règle unique air-sshd::cert_revocation::certificate_is_revoked (ADR-138) ;
  • câblage serveur : air-sshd::authorize::CertAwareAuthorizer (implémente authorize_cert), routé depuis userauth::authorize_publickey dès que l’algorithme vaut ssh-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 :

  1. le codec RPC (monitor::proto) n’a aucun message pour un certificat ;
  2. le monitor (MonitorAuthorizer) ne charge ni CA de confiance ni magasin de révocation — il ne consulte qu’authorized_keys ;
  3. le démon refuse de démarrer si trusted_ca et 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 ALLOW contraint (commande forcée + permissions) ou DENY muet.

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-v01 seul 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 :

  1. parse le blob (D3) ; type ≠ UserDENY ;
  2. exécute verify_certificate avec une AirCertPolicy montée côté monitor : now = horloge monitor (D4) ; required_kind = User ; principal = username ; is_ca_trustedis_trusted_ca(scope = User) sur les CA chargées (D6) ; is_revokedcert_revocation::certificate_is_revoked sur le magasin de révocation (D6) ; known_critical_options = { force-command, source-address } ;
  3. 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, motif authorize_with_reason existant) ;
    • Accepted ⇒ le monitor traduit le certificat en AuthorizeConstraint puis répond AuthorizeAllow :
      • extensionsSessionPermissions : 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 contre peer_address, en réutilisant le matcher d’adresse de from= (ADR-128/V2.5). Pair inconnu et source-address présente ⇒ DENY (fail-closed, comme PeerAddressUnknown/SourceNotAllowed).

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 de AuthorizeCertRequest + round-trip stable (le nouveau tag 4 entre 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 Accepted complet ⇒ DENY). (verify_certificate et le codec cert sont 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 (tag 4) dans monitor::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 porte trusted_cas / revocation / known_critical_options, exécute verify_certificate (D5), traduit AcceptedAuthorizeConstraint (extensions → permissions ; force-commandforced_command ; source-address → matcher from=). Bras AUTHORIZE_CERT dans monitor::service (MonitorPolicy). Tests unitaires + KAT (cert valide → ALLOW contraint ; CA inconnue/expiré/révoqué/mauvais principal → DENY).
  • 2.c — enfant : MonitorAuthorizedKeys::authorize_cert dé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-EXIT documentée) : ssh -i <cert> accepté sous privsep, cert révoqué/expiré refusé muet.

Alternatives rejetées

  • Surcharger AUTHORIZE (blob de cert dans public_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).
  • now fourni 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

MorceauSous-étape
Message AuthorizeCertRequest (tag 4) + fuzzair-sshd/src/monitor/proto.rs, fuzz/…monitor_proto.rs2.a
Politique de décision cert (verify + traduction)air-sshd/src/monitor/authorize.rs2.b
Bras AUTHORIZE_CERT de la boucleair-sshd/src/monitor/service.rs2.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 privsepair-sshd/src/bin/air-sshd.rs (resolve_privsep)2.d
Test bout-en-bout privsepair-sshd (tests privilégiés)2.e
(réutilisés tels quels) verify_certificate, is_trusted_ca, AirRevocationStore, certificate_is_revokedair-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éel ssh -i cert).
  • Enregistrement : docs/SUMMARY.md, docs/adrs/registre-adrs-fr.md, note docs/etat-avancement.md.