ADR-127 — Descellement additif couche-1-v3.2 : l’identité effective temporaire (AirTemporaryIdentity)
Statut : Accepté (2026-07-28). 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). Implémente le prérequis couche 1 d’ADR-124 §D5 (incrément V2.4), dans la continuité d’ADR-125.
Catégorie : Descellement ADDITIF de la couche 1 scellée
(couche-1-v3.1 → couche-1-v3.2). Aucun retrait, aucun changement de
signature : toute surface antérieure reste valide telle quelle.
Le tag n’est pas posé par cet ADR. Conformément au modèle de re-sceau, la pose du tag
couche-1-v3.2revient au superviseur, après revue.
Contexte
ADR-124 §D5 confie au monitor
privsep une opération qu’aucune autre partie d’Air n’avait eu à nommer : lire un
fichier en tant qu’un autre compte, sans détenir CAP_DAC_READ_SEARCH.
Le monitor est root, mais borné à {SETUID, SETGID, CHOWN, KILL}
(ADR-122 §6). Il ne peut donc pas lire
« parce qu’il est root » : privé de CAP_DAC_OVERRIDE, le noyau lui applique le DAC
ordinaire. C’est voulu — c’est ce qui garantit qu’un monitor bogué (chemin mal
construit, username malicieux) ne lira pas un fichier qui ne regarde pas le compte
demandé. La seule voie légitime est d’abaisser l’identité effective le temps de
l’openat, puis de la restaurer.
La couche 1 savait déjà poser des identifiants (credentials::set_uids/set_gids/
set_supplementary_groups, additif couche-1-v1.4) et larguer définitivement
des privilèges (drop_privileges). Elle ne savait pas dire « temporairement, et
reviens quoi qu’il arrive ». Or c’est là que se cachent les failles :
- un
euidresté abaissé transforme le processus privilégié en processus ordinaire — il ne peut plussetuidses workers, et il continue de servir sous une identité qu’il n’a pas choisie ; - une restauration oubliée sur un chemin d’erreur (un
?de plus, un refus StrictModes, une panique) est indétectable à la relecture ; - toucher aux composants réel ou sauvé vide l’ensemble permitted
(
capabilities(7)) et rend le retour à root définitivement impossible.
air-sshd étant couche 2, le layering (ADR-077)
lui interdit d’appeler la couche 0 en direct — et écrire cette séquence à la main dans
un service, une fois par service, est exactement ce qu’un sceau de couche doit
empêcher.
Décision
Descellement additif couche-1-v3.2, sur une crate.
air-process::credentials — la garde RAII
AirTemporaryIdentity::assume(uid, gid)— abaisse l’identité effective du fil courant vers(uid, gid), groupes supplémentaires vidés, et la restaure auDrop.MAX_RESTORABLE_GROUPS(64) — borne des groupes mémorisés pour la restauration (tampon de pile, zéro allocation). Au-delà : échec, jamais de restauration partielle.EXIT_EFFECTIVE_IDENTITY_NOT_RESTORED(126) — code de sortie du processus si la restauration échouait.
Quatre décisions de conception, chacune motivée par une façon de se tromper :
-
Effectif seulement.
setresuid(-1, uid, -1)/setresgid(-1, gid, -1). Réel et sauvé restent à 0 : le noyau autorise toujours un processus à poser en effectif l’une de ses trois valeurs courantes, sans aucune capability. C’est ce qui rend le retour structurellement garanti — et c’est pourquoiassumeexigereal == effective == saved == 0(sinonPermissionDenied, sans rien modifier). -
Groupes vidés, pas remplacés. Garder ceux de root serait plus permissif que l’utilisateur lui-même (un groupe de root pourrait ouvrir un répertoire que le compte cible ne peut pas traverser) ; poser ceux du compte exigerait de résoudre
/etc/groupdans le privilège. Vider est strictement moins-disant que l’identité réelle du compte : fail-closed. Les groupes précédents sont restaurés auDrop, et seulement s’ils ont effectivement été vidés (restaurer ce qu’on n’a pas changé pourrait échouer là où ne rien faire réussit). -
Ordre imposé, dans les deux sens. Descente : groupes, puis gid, puis uid — tant qu’on est root. Remontée : uid d’abord (il restaure l’ensemble effective des capabilities, donc
CAP_SETGID), puis gid, puis groupes. La garde existe avant la première mutation : tout échec en cours de descente passe donc par sonDrop, et remet en état ce qui avait déjà bougé. -
Échec de restauration = mort du processus. Le
Dropne peut pas rendre d’erreur, et un processus privilégié qui n’a pas pu redevenir ce qu’il était n’a plus d’état connu. Il sort parexit_group([EXIT_EFFECTIVE_IDENTITY_NOT_RESTORED]). Cette branche est inatteignable par construction (cf. décision 1) — et c’est précisément pour cela qu’aucune autre issue n’est acceptable : si l’impossible arrivait, le silence serait la faille.
Contrat d’emploi, gravé dans la documentation
- Mono-fil. Les wrappers Air appellent
setresuid/setresgiddirectement — pas de synchronisation inter-fils à la glibc : l’abaissement ne vaut que pour le fil courant. Emploi prévu : le monitor privsep, mono-fil, post-fork. - Pas d’imbrication. Une seconde garde échoue (l’identité n’est plus root) — ce qui est le comportement voulu : on ne descend pas deux fois.
Conséquences
- ADR-124 V2.4 devient exprimable sans que la couche 2 touche la couche 0 : le
monitor pose la garde, lit le magasin
authorized_keyssous StrictModes, et rend une décision. La preuve est faite dans le monitor réel (capabilities réduites) : sans abaissement, la lecture échoue ; avec, elle réussit ; après, l’euidest revenu à 0 (air-sshd/tests/authorize_root.rs). - Couverture. Le chemin nominal (descente, imbrication refusée, restauration,
restauration après panique) est couvert en process sous le gate root. Reste au
registre
docs/COVERAGE-EXCEPTIONS.mdla seule branche STRUCTURAL : la sortie du processus sur échec de restauration. - Aucun retrait, aucune signature modifiée. La surface
couche-1-v3.1reste valide à l’identique.
Ce que cet ADR ne fait pas
- Aucun tag.
couche-1-v3.2est posé par le superviseur. - Aucune modification de la couche 0. Tout est bâti sur
set_resuid/set_resgid/set_groups/get_groups, déjà scellés (ADR-021). - Aucune politique. La garde ne sait rien d’
authorized_keys, de SSH ni de StrictModes : elle abaisse et restaure, c’est tout. La politique vit en couche 2.
Alternatives rejetées
- Écrire la séquence à la main dans
air-sshd. Rejeté : elle exige la couche 0 (interdit à la couche 2, ADR-077) ou une composition subtile de trois appels dont l’ordre est piégeux dans les deux sens. Une faille par service. - Rendre une erreur au lieu de mourir quand la restauration échoue. Rejeté : rien ne garantit que l’appelant la traite, et l’état intermédiaire — privilégié à moitié — est le pire des deux mondes.
- Conserver les groupes de root pendant la fenêtre. Rejeté : plus permissif que l’utilisateur, donc capable d’ouvrir ce que le compte cible ne peut pas ouvrir. La fenêtre doit être moins-disante, jamais plus.
- Donner
CAP_DAC_READ_SEARCHau monitor. Déjà rejeté par ADR-122/ADR-124 : lire en tant que l’utilisateur laisse le noyau appliquer le DAC, et retire une capability au processus privilégié.