ADR-138 — Descellement additif couche-1-v3.7 : révocation par serial de certificat et interop du format KRL OpenSSH dans air-keystore
Statut : Accepté (2026-07-30). RFC de structure (ADR-015). Applique le modèle de re-sceau du sceau couche 1 (ADR-062) sous la délégation additive du superviseur (ADR-065). Met en œuvre ADR-109 §4 (révocation), dont il est l’incrément d’exécution n°3.
Catégorie : Descellement ADDITIF de la couche 1 scellée
(couche-1-v3.6 → couche-1-v3.7). Aucun retrait, aucune signature
modifiée, aucun changement de comportement d’une primitive scellée sur les
données existantes : toute surface antérieure d’air-keystore reste valide telle
quelle. Ce qui est ajouté est un module neuf (air_keystore::krl) et quatre
types + un agrégat dans un module existant (air_keystore::revocation).
Le tag n’est pas posé par cet ADR. Conformément au modèle de re-sceau, la pose du tag
couche-1-v3.7revient au superviseur, après revue.
Contexte
Depuis ADR-108, air-keystore
sait révoquer une clé publique, par son blob complet : AirRevokedKey,
is_revoked, magasin scellé revoked.aircfg. C’est ce que la vérification de
certificat consulte depuis ADR-109 (incréments d.2.b serveur et inc.1 client).
Or ADR-109 §4 demande deux choses de plus, et ce sont précisément celles qui font qu’une révocation tient à l’échelle d’une flotte :
- La révocation par
serial. Un certificat porte un numéro de série. Révoquer « le certificat n° 4242 émis par notre CA » ne demande pas d’avoir la clé sous la main — c’est ce qui permet de révoquer depuis un poste d’administration, à partir d’un registre d’émission, sans collecter les clés de la flotte. Sans cela, Air ne sait révoquer que ce qu’il détient déjà. - L’interop du format KRL OpenSSH. Le KRL est le format que
ssh-keygen -kproduit et quesshdconsomme. Une flotte mixte (des machines Air, des machines OpenSSH) doit pouvoir n’entretenir qu’une liste de révocation. Sans interop, Air impose un second artefact — c’est-à-dire, en pratique, un artefact qui diverge.
Rien de scellé n’empêche l’un ni l’autre. Mais les deux étendent la surface publique de la couche 1, et le sceau porte sur la surface. D’où le présent ADR : il n’y a rien à desceller, il y a quelque chose à inscrire.
Décision
Descellement additif couche-1-v3.7, sur une seule crate : air-keystore.
1. Le serial est relatif à la CA émettrice — et c’est le cœur de la décision
Un serial n’a de sens que chez l’émetteur qui l’a attribué : deux CA distinctes
numérotent librement leurs certificats, et le n° 42 de l’une n’a aucun rapport avec le
n° 42 de l’autre. Une entrée de révocation par serial porte donc la CA visée —
c’est un invariant de type, pas une convention :
#![allow(unused)]
fn main() {
AirRevokedSerial::new(ca_key: Option<AirSshPublicKey>, serial_min, serial_max, revoked_at)
}
- Plage, bornes incluses (
serial_min == serial_max= un serial unique) : elle couvre leserial: Ncomme leserial: A-Bd’OpenSSH, et les runs qu’on tire d’un bitmap KRL, avec un seul type. None= joker « toute CA » — la sectioncertificatesdu KRL admet une CA vide. C’est le côté sûr : le joker révoque plus, jamais moins.- Le serial
0n’est jamais révocable. Chez OpenSSH,0signifie « pas de serial » ; le révoquer reviendrait à révoquer tous les certificats sans serial de la CA. C’est refusé au décodage (artefact et KRL) et par construction à l’interrogation.
Deux autres façons de désigner, qui viennent avec le KRL et qu’on ne peut pas
importer sans les stocker : AirRevokedCertKeyId (le key id, relatif à la CA
lui aussi — c’est ainsi qu’OpenSSH révoque un certificat sans serial) et
AirRevokedFingerprint (l’empreinte SHA-256 d’une clé dont on n’a pas le blob).
2. Un magasin, quatre listes : AirRevocationStore
Le magasin devient un agrégat — clés, empreintes, plages de serial, key id — et
c’est lui qu’on interroge, qu’on charge et qu’on écrit :
decode_artifact/encode_artifact/load/save, is_key_revoked,
is_serial_revoked, is_cert_key_id_revoked.
Il n’y a pas de variante « clés seules » côté consommateur : un toit qui ne
chargerait que les clés révoquerait moins que ce que l’administrateur a inscrit,
et ce genre d’écart ne se voit pas en test. AirRevocationStore::empty() est
const fn par contrat, pour qu’un toit sans magasin configuré tienne un magasin
vide dans un static plutôt qu’un second chemin de code.
Le schéma Cap’n Proto est étendu additivement (serials @2, certKeyIds @3,
fingerprints @4) : un revoked.aircfg écrit avant cet ajout décode en listes
vides, et un magasin écrit après reste lisible par la face historique. Les quatre
fonctions libres d’origine (decode_artifact, encode_artifact, load, save,
is_revoked) gardent exactement leur signature et leur sémantique observable.
3. Le codec KRL vit en couche 1, sans-IO, fuzzé
Le module neuf air_keystore::krl est un cœur pur (ADR-091) :
AirKrl::decode/encode (octets ↔ structure), pilote I/O mince
(load/save), et un pont explicite vers le magasin
(to_revocation_store/from_revocation_store). Le format vient de PROTOCOL.krl
d’OpenSSH ; le codec est fuzzé (fuzz_air_keystore_krl, avec round-trip et
scellement du magasin importé vérifiés à chaque itération) et proptesté
(decode ∘ encode = identité).
Pourquoi la couche 1 et non la couche 2, alors que le format de certificat est en
couche 2 (ADR-109 §1) ? Parce qu’un KRL ne parle pas du fil : c’est la forme
d’échange du matériel de révocation, que le keystore possède déjà. Le codec ne
connaît ni transport, ni session, ni air-ssh-proto — il ne parse même pas les
certificats, seulement les désignations de certificats.
Périmètre couvert (décodage et encodage) : en-tête ; section certificates
(CA typée ou joker) avec serial-list, serial-range, serial-bitmap et
cert-key-id ; section explicit-key ; section fingerprint-sha256 ; extensions
non critiques ignorées. L’interop est prouvée dans les deux sens : les
vecteurs KAT des tests sont des KRL réellement produits par ssh-keygen
(OpenSSH_10.2p1), qu’Air décode et réécrit octet pour octet ; et un test
d’intégration confronte le codec au binaire installé, dont un export d’Air relu par
ssh-keygen -Q -l.
4. Fail-closed : ce que le codec refuse
Un KRL qu’on ne comprend pas entièrement est une erreur, jamais un « rien de
révoqué » : c’est la seule posture sûre pour une liste de révocation, et c’est
l’inverse du réflexe d’interop habituel (« ignorer ce qu’on ne connaît pas »). Sont
refusés : magie/version inattendues, troncature, drapeaux ou reserved non nuls,
extension critique inconnue, serial nul, plage inversée, empreinte de longueur
≠ 32, mpint de bitmap non canonique (négatif ou à zéros de tête — deux
encodages du même nombre ouvriraient une ambiguïté), entrée > 1 MiB, bitmap
débordant u64, et plus de MAX_REVOKED_SERIAL_RANGES (65 536) plages importées
depuis un même KRL (un bitmap alterné est compact à écrire et ruineux à développer).
Une CA non Ed25519 dans une section certificates fait échouer l’import —
plutôt que de devenir silencieusement un joker, ce qui révoquerait toute la flotte.
Une clé de la section explicit-key qu’Air ne sait pas typer est en revanche
conservée par son empreinte : le hash d’un blob est toujours calculable, donc
rien n’est perdu ni ignoré.
5. Deux sections KRL différées, explicitement
fingerprint-sha1— Air est Ed25519/SHA-256 moderne-only (ADR-094) et n’a pas de SHA-1 : accepter la section serait prétendre honorer une révocation qu’on ne peut pas évaluer. Refusée, pas ignorée.signature— la signature d’un KRL. OpenSSH ne l’émet plus (vérifié surOpenSSH_10.2p1: pour-k, l’option-sdésigne la CA dont on révoque les certificats, pas une clé de signature du KRL), et Air ne saurait pas à quelle clé faire confiance à cet endroit : la question « qui a le droit de me dire ce qui est révoqué ? » est une décision de politique, pas une propriété du format. L’intégrité d’une liste de révocation au repos relève, chez Air, de l’enveloppe scellée du magasin (AIRCFGv1+ checksum, ADR-108 §2) et des permissions du fichier. La section est refusée ; si le besoin d’un KRL signé apparaît (distribution par canal non fiable), il fera l’objet d’une décision à part — cf. §Points remontés.
6. Le câblage : une règle de révocation de certificat, en couche 2
Côté air-sshd, la règle « ce certificat est-il révoqué ? » vit désormais dans un
seul endroit, air_sshd::cert_revocation, appelé par le serveur
(CertAwareAuthorizer, certificats utilisateur) et par le client
(ClientHostTrust, certificats d’hôte) — là où deux copies vivaient. Elle consulte
les trois voies : clé certifiée (blob ou empreinte), serial pour la CA
signataire, key id pour cette même CA. Les interrogations sont toutes
évaluées puis combinées par | non court-circuitant, comme l’is_revoked d’origine :
la durée de la réponse ne dit ni laquelle a déclenché le refus, ni combien
d’entrées ont été comparées.
Aucun nouveau réglage de configuration : le magasin déjà désigné par
revokedKeysPath (serveur) / revokedHostKeysPath (client) porte les serials,
puisque le schéma est additif.
Conséquences
Positives.
- Révocation à l’échelle d’une flotte : on révoque un numéro de certificat sans détenir la clé, depuis un registre d’émission — ce que ADR-109 §4 exigeait.
- Un seul artefact de révocation pour une flotte mixte Air/OpenSSH, dans les deux sens, prouvé octet pour octet contre le binaire réel.
- Une seule règle de révocation de certificat en couche 2 : un cas oublié dans une révocation, c’est une porte ouverte ; il n’y a plus qu’un endroit où l’oublier.
- Rétro-compatibilité stricte : magasins existants lisibles, signatures historiques intactes, aucun réglage à changer.
Négatives / coûts assumés.
- Une surface de parsing de plus, et elle est hostile (un KRL vient d’un fichier qu’on ne contrôle pas). Coût payé en fuzz, proptest, couverture 100 % et fail-closed systématique — risque borné, pas nul.
- Quatre listes à interroger au lieu d’une : la vérification d’un certificat fait plus de travail. Assumé (Principe 5) — la révocation est sur le chemin d’authentification, pas dans une boucle chaude, et le parcours sans court-circuit est un choix de sécurité, pas une négligence.
- Deux sections KRL refusées : un KRL OpenSSH qui les porte n’est pas importable dans Air. C’est visible et documenté, plutôt que silencieusement partiel.
Ce que cet ADR ne fait pas
- Aucun retrait, aucune signature modifiée en couche 1 :
is_revoked,decode_artifact,encode_artifact,load,save,AirRevokedKeysont inchangés. (Le décodage historique valide désormais aussi les champs additifs de l’artefact — un magasin dont les serials sont incohérents est refusé en entier. Aucun artefact antérieur n’en porte, donc aucun n’est affecté.) - Aucune CLI.
air-keystore krl import|exportetcert revokerelèvent de la CLI (ADR-115, vague 2 #3) ; ici, la capacité seulement. - Aucune signature de KRL, ni production ni vérification (§5).
- Aucun rechargement à chaud du magasin : les toits le chargent au démarrage, comme avant.
Alternatives rejetées
- Stocker le
serialsans la CA. Rejeté : ce serait la faille. Le serial 42 d’une CA de test révoquerait le serial 42 de la CA de production — ou l’inverse, silence compris. - Convertir un bitmap KRL en liste de serials explicites. Rejeté : 1 MiB de bitmap alterné, c’est plus de 4 millions d’entrées dans le démon d’authentification. La compression par runs (plages contiguës) dit la même chose, et la borne dure refuse le reste.
- Ignorer les sections KRL inconnues (le réflexe d’interop). Rejeté : une liste de révocation partiellement comprise est une liste qui relâche. Fail-closed.
- Mettre le codec KRL en couche 2, avec le format de certificat. Rejeté : le KRL ne parle pas du fil, il transporte du matériel de révocation — la matière même du keystore. L’y mettre obligerait la couche 1 à dépendre de la couche 2 pour charger son propre magasin.
- Un second fichier de configuration pour les serials. Rejeté : un artefact de plus, c’est un artefact qui diverge. Le schéma additif l’évite entièrement.
Points remontés au BDFL
- Un module couche 1 de plus, donc un re-sceau. Rien de scellé n’a bougé ; c’est
la surface de la couche qui s’étend. Le tag
couche-1-v3.7est à poser après revue. - La signature de KRL est différée — est-ce le bon arbitrage ? OpenSSH ne signe plus ses KRL, et l’intégrité au repos est couverte par l’enveloppe scellée. Mais si la distribution d’un KRL de flotte devait passer par un canal non fiable (dépôt HTTP, image de provisioning), la question « qui a le droit de me dire ce qui est révoqué ? » deviendrait réelle. Elle appelle alors une décision de politique (quelle clé signe, où sa confiance est ancrée), pas un ajout de codec.
fingerprint-sha1refusée plutôt qu’ignorée. Conséquence : un KRL de flotte ancienne peut être non importable dans Air. C’est voulu (on ne prétend pas honorer ce qu’on ne peut pas évaluer), mais c’est une friction d’adoption réelle, à connaître avant qu’un opérateur ne la découvre.- La borne de 65 536 plages est un chiffre choisi, pas mesuré. Il tient large pour toute flotte réaliste et refuse l’abus ; à revoir le jour où une flotte réelle s’en approche.