ADR-093 — Couche transport SSH-2 d’air-sshd : cœur sans-IO air-ssh-proto, topologie de crates v1/v2, jeu d’algorithmes moderne, wire-compat OpenSSH
Statut : Accepté (2026-07-13, décision BDFL). Met en œuvre la
phase 1 d’ADR-074 (vision air-sshd) selon le
motif obligatoire d’ADR-091 (réseau sans-IO).
Intègre la directive BDFL du 2026-07-13 : air-sshd v1 lancé par systemd
(bon citoyen, a minima), v2 reposant sur le framework réseau air-network (une
stack « ssh ») et lancé par air-launchd ; air-sshd publie ses événements de
session sur AirCom ; configuration binaire via air-config. S’appuie sur
ADR-092 (air-async),
ADR-034 (air-crypto),
ADR-069 (air-socket/air-handle),
ADR-001 (AirCom), ADR-005 (systemd),
ADR-033/ADR-040/ADR-073
(configuration binaire).
Catégorie : Architecture (couche 2, premier service réseau). Toute évolution structurante passe par un RFC (ADR-015).
Contexte
ADR-074 découpe air-sshd en trois phases, chacune
instruite par un sous-ADR : (1) couche transport SSH-2, (2) ssh-userauth,
(3) ssh-connection (canaux/PTY/shell). Le présent ADR instruit la phase 1 — mais
fige la topologie de crates de tout air-sshd, car la directive BDFL impose des
contraintes qui traversent les phases et qui doivent être honorées dès la première
ligne (le découpage en crates est le seul choix qu’on ne peut pas rattraper après coup).
Cinq faits cadrent la décision :
- SSH n’utilise pas TLS. Le transport SSH-2 (RFC 4253) a son propre échange de
version, sa négociation (
KEXINIT), son KEX et son protocole de paquets binaire chiffré. Le framework génériqueair-network/air-tls(HTTP/QUIC) est hors du chemin critique de la v1 — mais la v2 l’utilisera (cf. §1.4). - Le socle est prêt.
air-async,air-socket,air-crypto(profil SSH moderne réellement câblé),air-config(~16 k lignes, ADR-033/040/073) et AirCom (inc.1→7, events pub-sub §7 +callsur objet) existent, scellés/testés.air-sshdn’a besoin ni de la façade libc C ni delibcryptoC-ABI (ADR-074). air-sshdest une fonction de forçage. Il produit des facilités partagées que tout service Air réutilisera — notamment l’hôte de service (§1.3) et le motif de publication d’événements sur AirCom (§5), pas des briques jetables propres à ssh.- Contrainte v1/v2 (directive BDFL). v1 :
air-sshdest lancé et supervisé par systemd (socket-activation +sd_notify, intégration a minima). v2 : il repose surair-network(une stack « ssh ») et est lancé parair-launchd(le superviseur natif d’Air, à venir). La conception des crates doit rendre ce passage v1→v2 propre — idéalement sans toucher au cœur protocolaire. - Observabilité (directive BDFL). Un consommateur AirCom doit pouvoir suivre les sessions : demande de connexion depuis telle machine, négociation ok/ko, shell lancé pour l’utilisateur X, session fermée — et interroger qui est connecté et où.
Décision
1. Topologie de crates — un cœur invariant, une périphérie swappable
Le motif sans-IO (ADR-091) est précisément ce qui rend le passage v1→v2 propre. On sépare ce qui ne change jamais (le protocole) de ce qui change (l’I/O, l’hôte).
1.1 air-ssh-proto — cœur PUR sans-IO (invariant v1↔v2)
Couche 2, no_std + alloc, #![forbid(unsafe_code)], zéro fd, zéro socket, zéro
horloge, zéro I/O. La machine octets entrants → messages typés + octets sortants du
protocole SSH-2 : Framer (paquets, RFC 4253 §6), Codec (SSH_MSG_*),
StateMachine (transport + rekey), Handshaker (version + KEXINIT + KEX +
dérivation), Timer (fenêtre de rekey). Flow/Mux/Session réservés (phase 3, canaux).
Il ne sait jamais d’où viennent les octets → réutilisé verbatim en v2, sans une
ligne changée. C’est l’invariant central.
1.2 air-sshd — le démon, scindé en deux responsabilités
- Pilote de transport d’octets (la seule pièce que la v2 remplace) — déplace les
octets entre le socket et le cœur, exécute les primitives
air-crypto, fournit l’aléa. Derrière un trait minceSshByteStream(lecture/écriture async d’octets ordonnés). v1 : implémenté surair-async/air-socket(io_uring). v2 : implémenté par la stack « ssh » d’air-network(§1.4). - Couche service (invariante v1↔v2) —
SessionRegistry(source de vérité des sessions vivantes, §5), publication AirCom (§5), configuration (air-config, §7), hôte de service (§1.3), et — aux phases 2/3 — privsep (air-process), PTY (air-terminal), politique de login (air-account).
1.3 air-service — hôte de service réutilisable (facilité partagée)
Petite crate couche 2 exposant un trait ServiceHost qui abstrait d’où vient le
socket d’écoute, comment notifier le readiness/état, et comment recevoir l’ordre
d’arrêt. v1 = implémentation systemd (§6). v2 = implémentation air-launchd.
air-sshd (comme tout futur démon Air) ne référence jamais systemd en dur — il
dépend de ServiceHost. C’est le seam v1→v2 de la supervision, et une facilité que
tout service Air réutilise (fonction de forçage, ADR-074).
1.4 v2 — la stack « ssh » d’air-network enveloppe le cœur
En v2, air-network gagne une option de protocole « ssh » : elle n’implémente pas SSH
— elle enveloppe air-ssh-proto comme couche transport/sécurité d’une AirConnection,
et fournit un SshByteStream au-dessus du substrat air-network. v1 et v2 pilotent le
même cœur. Rien à jeter : la transition = remplacer le pilote d’octets (§1.2) par la
stack air-network. (Instruit par un ADR compagnon quand air-network sera bâti.)
2. Jeu d’algorithmes — moderne uniquement, refus du legacy par construction
KEXINIT n’annonce que ces algorithmes (aucun legacy proposé → aucun downgrade) :
| Rôle | Algorithme(s) | Référence |
|---|---|---|
| Échange de clés (KEX) | curve25519-sha256 (+ alias curve25519-sha256@libssh.org) | RFC 8731 |
| Clé d’hôte / signature | ssh-ed25519 | RFC 8709 |
| Chiffrement (AEAD) | chacha20-poly1305@openssh.com (primaire), aes256-gcm@openssh.com (secondaire) | PROTOCOL.chacha20poly1305 / RFC 5647 |
| MAC | implicite (AEAD intégré) — aucun hmac-* négocié | — |
| Compression | none | — |
Refusés (jamais annoncés) : RSA/DSA (clé d’hôte), diffie-hellman-group*, chiffrements
CBC/CTR non-AEAD, hmac-sha1/hmac-md5, compression zlib.
3. Format de paquet — deux régimes gérés par le Framer
- Avant
NEWKEYS: paquet binaire en clair RFC 4253 §6 (packet_length | padding_length | payload | random padding), sans MAC. - Après
NEWKEYS: construction AEAD compatible OpenSSH —chacha20-poly1305@openssh.com(longueur chiffrée par une clé séparée, corps par une autre, tag Poly1305 ;PROTOCOL.chacha20poly1305) ouaes256-gcm@openssh.com(longueur en clair = AAD, RFC 5647). Le Framer bascule surSSH_MSG_NEWKEYS.
4. Échange de clés et dérivation
- Bannière :
SSH-2.0-Air_<version>\r\n(version injectée par le pilote). KEXINIT(§2), puis KEXcurve25519-sha256:SSH_MSG_KEX_ECDH_INIT(Q_C) /SSH_MSG_KEX_ECDH_REPLY(K_S, Q_S, sig) — RFC 5656/RFC 8731.- Hash d’échange
H=SHA-256(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K); le serveur signeHavec sa clé d’hôte Ed25519 ;session_id = H(premier KEX). - Dérivation RFC 4253 §7.2 (
HASH = SHA-256, extension itérative). Rekey via le Timer du cœur (RFC 4253 §9), clés renouvelées sans interruption.
5. Publication AirCom des événements de session (directive BDFL)
air-sshd est le premier vrai citoyen AirCom : il publie le cycle de vie des
sessions et l’expose interrogeable. Deux facettes, sur un SessionRegistry unique
(source de vérité des sessions vivantes) :
- Flux d’événements (fire-and-forget, pub-sub AirCom §7) :
ConnectionRequested{peer},KexOk/KexFailed,AuthOk/AuthFailed{user, method, peer},SessionOpened{session_id, user, pty},ShellStarted{user},SessionClosed{session_id}… - État interrogeable (« qui est connecté, où ») :
air-sshdexpose un objet-service AirCom (list_sessions()/session_info(id)viacallsurAirObject, inc.7).
Le schéma d’événements/sessions (capnp) est figé dès la phase 1 ; les événements se
remplissent au fil des phases (P1 : ConnectionRequested, KexOk/KexFailed ;
P2 : Auth* ; P3 : SessionOpened/ShellStarted/SessionClosed).
Conséquence actée :
air-comdevient une dépendance de la v1 d’air-sshd(ADR-074 §4.3 disait « IPC pas requis pour un premier prototype » ; la directive BDFL prime). Coût nul : AirCom est livré (inc.1→7).
6. Hôte de service v1 = systemd (a minima), v2 = air-launchd
Implémentation systemd du trait ServiceHost (§1.3), minimale mais bon citoyen :
- Socket-activation :
sd_listen_fds(lireLISTEN_PID/LISTEN_FDS, fds à partir de 3) → le socket d’écoute vient de systemd (pas debinden propre si activé). sd_notify: datagramme AF_UNIX vers$NOTIFY_SOCKET(READY=1après binding des clés d’hôte,STOPPING=1,STATUS=…).- Arrêt propre :
SIGTERMviasignalfd(déjà en couche 0) → drain des sessions. - Unité systemd (
.socket+.service) fournie ; effort d’intégration au strict nécessaire pour être un bon citoyen (Type=notify, socket activation).
Ces deux protocoles (sd_listen_fds, sd_notify) sont triviaux en Rust safe (~35 l.
au total sur air-socket/air-env) — aucun binding libsystemd C. La v2 fournira une
implémentation air-launchd du même trait ServiceHost.
7. Configuration binaire (air-config, ADR-073) — existe déjà
air-config (capnp) est mature — on ne le bâtit pas, on définit un schéma
« sshd » par-dessus. Phase 1 : clés d’hôte (Ed25519), jeu d’algorithmes autorisé
(sous-ensemble du §2), paramètres d’écoute, bannière. Phase 2 : fichier de conf
binaire par-utilisateur dans le HomeDirectory (options user + clés autorisées ≈
authorized_keys), lu via air-config. Jamais de sshd_config texte (ADR-073) ;
import/export possibles.
8. Cible de compatibilité on-the-wire
Un client OpenSSH ssh de stock complète le handshake transport (atteint
SSH_MSG_NEWKEYS puis débute ssh-userauth) contre air-sshd. Critère d’acceptation de
la phase 1, prouvé par un test d’intégration exécutant un vrai client ssh. Le rôle
client d’air-sshd est hors phase 1.
9. Neutralité sync/async (ADR-038/092)
Pilote async 1ʳᵉ classe sur air-async ; façade bloquante de confort (harnais de test)
sur air-socket bloquant, pilotée par le même cœur (test croisé sync↔async, ADR-091).
10. Ordre des incréments (phase 1)
| Inc. | Contenu | Preuve |
|---|---|---|
| T.1 | Crates air-ssh-proto/air-sshd/air-service + traits de seam (SshByteStream, ServiceHost) + schéma capnp événements/sessions (figé) + échange d’identification + Framer paquet clair (RFC 4253 §6) + squelette Codec/StateMachine. Fuzz du parseur de paquets. Impl systemd minimale de ServiceHost. | banner échangé ; paquet clair round-trip ; ServiceHost systemd notifie READY |
| T.2 | KEXINIT + négociation (moderne only, first-match) + StateMachine transport (transitions illégales rejetées). Model-based tests. Événement AirCom ConnectionRequested publié. | négociation déterministe ; event visible d’un abonné AirCom |
| T.3 | KEX curve25519-sha256 (X25519, aléa injecté) + hash H + signature Ed25519 de la clé d’hôte + dérivation (RFC 4253 §7.2). Vecteurs de test. KexOk/KexFailed publiés. | H/clés conformes aux vecteurs |
| T.4 | NEWKEYS + régime chiffré chacha20-poly1305@openssh.com (+ aes256-gcm) + rekey. Wire-compat : un vrai client ssh atteint ssh-userauth. | ssh -v traverse le transport |
Chaque incrément : cœur ~100 % (unit + property + fuzz + model-based), pilote > 90 %, barrière verte, délégué à carbon puis re-vérifié et mergé (comme AirCom).
Conséquences
- Transition v1→v2 garantie par construction : le cœur
air-ssh-protoest réutilisé verbatim ; seuls le pilote d’octets (→ stackair-network) et l’implServiceHost(→air-launchd) changent. Aucune réécriture protocolaire. air-service= facilité partagée : tout démon Air (v1 systemd / v2 air-launchd) la réutilise ; c’est le modèle de service (prérequis §4.2 de la note d’archi) livré a minima.air-sshd= premier citoyen AirCom observable : events + objet-service interrogeable ;air-comdépendance v1 (assumée). Exerce concrètement AirCom (pub-sub §7 +callinc.7).- Zéro C, zéro
unsafedans le cœur ; chemin 100 % Rust safe (ni libc C nilibcrypto). - Arêtes de couche :
air-ssh-proto→air-crypto(2→1) ;air-sshd→air-async/air-socket/air-config/air-com/air-service/air-ssh-proto(2→2 et 2→1) ;air-service→air-socket/air-env/air-signal(2→1). Jamais 2→0 (check-layers). - Deux ADR compagnons à venir (v2) : (a) modèle de service /
air-launchd; (b) stack « ssh » d’air-networkenveloppantair-ssh-proto. - Sécurité : moderne only (aucun downgrade), AEAD, constant-time (
air-crypto), clé d’hôte Ed25519, aléa injecté et audité.
Alternatives rejetées
- Porter OpenSSH C / se lier à
libcrypto. Tranché par ADR-074. - Supporter le legacy (RSA/DSA,
dh-group14,hmac-sha1, CBC,zlib). Refusé (surface d’attaque, profil moderne ADR-074). - Attendre / passer par
air-network/air-tlsen v1. SSH n’est pas du TLS ; le framework générique est différé — mais la v2 l’utilisera via une stack « ssh » enveloppant le même cœur (§1.4), d’où la topologie choisie. - Câbler systemd en dur dans
air-sshd. Refusé : le traitServiceHost(air-service) est le seam v1(systemd)→v2(air-launchd) et une facilité partagée. - Réimplémenter SSH dans
air-networken v2. Refusé : la stack « ssh » enveloppeair-ssh-proto, elle ne le duplique pas. - Configuration texte façon
sshd_config/ serde. Refusé :air-configcapnp (ADR-073) existe ; serde le dupliquerait (règle des 80 %, ADR-024). - Cœur appelant le socket / tirant son aléa en interne. Violerait le motif sans-IO (ADR-091) et interdirait le fuzzing déterministe.
Références : RFC 4251 (architecture), RFC 4253 (transport SSH-2), RFC 5656 (ECC),
RFC 8731 (curve25519-sha256), RFC 8709 (ssh-ed25519), RFC 5647 (AES-GCM),
OpenSSH PROTOCOL.chacha20poly1305.