Mesure — le mécanisme noyau du seuil d’exécution (ADR-188 D3)
Statut : deux phases mesurées le 2026-09-28 — la première sur carbon et neon, sans
redémarrage ; la seconde sur helium (aarch64, Raspberry Pi 4) puis sur hydrogen
(x86_64), les deux machines que le BDFL a autorisées pour un redémarrage (§5, §5.5). Décision
du BDFL le même jour : voie (ii) — amendements d’ADR-188 D3 et d’ADR-189 D4.
Sert à : trancher ADR-188 D3 (quel mécanisme applique le seuil à chaque execve) et
ADR-189 D4 (le noyau vérifie-t-il une signature, ou une empreinte dans une liste tenue par Air).
1. Ce qui a été mesuré, et comment
Sur carbon (x86_64, Ubuntu, noyau 7.0.0-34-generic) et neon (aarch64, Raspberry Pi,
noyau 7.0.0-1020-raspi), en lecture seule : configuration du noyau (/boot/config-*), modules
de sécurité actifs (/sys/kernel/security/lsm), paramètre de démarrage (/proc/cmdline), état
d’IMA, trousseaux du noyau (/proc/keys), fonctions noyau disponibles aux programmes eBPF
(/proc/kallsyms), système de fichiers racine (tune2fs -l), mode de démarrage.
Puis, sur carbon seulement (neon n’a pas bpftrace), la couverture des points
d’accroche : bpftrace observe security_bprm_check, security_mmap_file (avec
PROT_EXEC) et security_file_mprotect pendant quatre gestes — un binaire, un script #!, un
dlopen depuis Python, le chargeur dynamique. C’est une observation : aucun module n’est
activé, rien n’est bloqué.
2. Les trois mécanismes, sur nos deux machines
| carbon | neon | |
|---|---|---|
| Modules actifs | lockdown,capability,landlock,yama,apparmor,ima,evm | idem |
| IPE compilé | oui — non actif | oui — non actif |
| IPE : propriétés | fs-verity (+ signature), dm-verity (+ signature) | dm-verity seulement |
| fs-verity dans le noyau | oui (signatures intégrées) | non |
Fonction verity de l’ext4 racine | non | non |
| IMA | actif, aucune politique (1 seule mesure : le démarrage) | idem |
| IMA : évaluation | compilée, réglable au démarrage (ima_appraise=) | idem |
| IMA : politique | fichier en écriture seule, illisible | idem |
| BPF LSM compilé | oui — non actif | oui — non actif |
eBPF : bpf_ima_file_hash | oui | oui |
eBPF : bpf_get_fsverity_digest | oui | non |
eBPF : bpf_verify_pkcs7_signature, bpf_lookup_system_key, bpf_get_file_xattr | oui | oui |
| Signatures que le noyau sait vérifier | RSA (intégré), ECDSA (module) — pas Ed25519 | idem |
| Démarrage | UEFI + GRUB (/etc/default/grub) | firmware du Pi (/boot/firmware/cmdline.txt) |
| Accès de secours | OpenSSH actif (port 52222) | OpenSSH actif (port 22) |
IPE et BPF LSM ne sont pas dans la liste par défaut du noyau (CONFIG_LSM) : les activer
demande d’ajouter lsm=…,ipe,bpf au paramètre de démarrage, donc un redémarrage.
3. La couverture des points d’accroche (carbon)
| Geste | bprm_check | mmap_file exécutable |
|---|---|---|
execve d’un binaire (/usr/bin/true) | une fois, le binaire | le binaire, ld-linux, libc |
script #!/bin/sh | deux fois : le script, puis l’interprète /bin/sh | dash, ld-linux, libc |
dlopen depuis Python (libz, _ctypes) | jamais | oui : chaque bibliothèque chargée |
chargeur dynamique ld-linux | jamais | oui |
Deux conséquences :
- Un script
#!fait vérifier son interprète : la règle du BDFL — « un Python Air, jamais un Python venu de nulle part » — est applicable au pointbprm_check. - Un contrôle posé au seul
execvelaisserait passer toute bibliothèque chargée pardlopen, et le chargeur lui-même. Le seuil doit couvrir aussimmap_fileavecPROT_EXEC(etfile_mprotect). IMA le fait (MMAP_CHECK), IPE aussi (op=EXECUTE), un programme BPF LSM le peut (lsm/mmap_file).
4. Ce que la première phase permet déjà de dire
- IPE n’est pas utilisable aujourd’hui sur les deux machines. Sur neon, faute de fs-verity,
il ne peut s’appuyer que sur
dm-verity, c’est-à-dire sur une image système en lecture seule qu’Air n’a pas encore. Sur carbon, il faudrait aussi activer la fonctionverityde l’ext4. IPE redeviendra le bon candidat avec une image système Air vérifiée (dm-verity). - IMA (évaluation) est utilisable sur les deux, mais la voie est lourde et risquée à éprouver :
une politique écrite à chaud ne se retire qu’au redémarrage ; une évaluation par empreinte
n’est sûre que si EVM protège l’attribut étendu (clé ou TPM) ; une évaluation par signature
demande une clé RSA ou ECDSA dans le trousseau
.ima— la voie (i) d’ADR-189 D4. - BPF LSM est le seul mécanisme utilisable à l’identique sur carbon et neon aujourd’hui.
bpf_ima_file_hashy donne l’empreinte d’un fichier exécuté ; un programme peut la chercher dans une table tenue parair-bundle— la voie (ii) d’ADR-189 D4, avec les seules clés Ed25519 d’Air. Il peut aussi vérifier une signature PKCS#7 portée par un attribut étendu — la voie (i). La couche 0 d’Air sait déjà charger un programme de typeLsmet l’attacher (crates/air-sys-types/src/ebpf.rs).
Rien de cela ne tranche encore : il manque ce qui ne se mesure qu’une fois le module actif.
5. La seconde phase — mesurée sur helium
La machine. helium : Raspberry Pi 4, Ubuntu 26.04, noyau 7.0.0-1020-raspi — la même
configuration de noyau que neon. carbon a été exclu par le BDFL : une session de travail y
tourne, et ce n’est pas une machine de test.
Le redémarrage, sans risque de perte. Le Pi démarre par tryboot_a_b : une copie de
/boot/firmware/current/ en new/, dont seule la ligne de démarrage diffère
(lsm=lockdown,capability,landlock,yama,apparmor,ima,evm,ipe,bpf), puis reboot "0 tryboot".
Ce démarrage ne vaut qu’une fois : le suivant revient à current/, et panic=10 y
ramènerait de lui-même une machine en panique. Résultat : les modules actifs deviennent
lockdown,capability,landlock,yama,apparmor,ipe,bpf,ima,evm, et l’interface d’IPE apparaît,
sans politique.
Le programme de mesure — un programme BPF LSM en journal seul, qui ne refuse jamais rien :
sur bprm_check_security et mmap_file (avec PROT_EXEC), il calcule l’empreinte du fichier
par bpf_ima_file_hash et compte les cas et le temps passé ; sur file_mprotect, il compte.
Compilé sur helium (clang -target bpf), chargé par bpftool prog loadall … autoattach.
5.1 Sans cache, le coût est prohibitif
Temps par execve (boucle bash) | sans programme | avec le programme | après retrait |
|---|---|---|---|
/bin/true | 2,65 ms | 19,3 ms | 2,66 ms |
air-bundle (binaire Air, static-PIE, marqué) | 2,16 ms | 70,9 ms | 2,23 ms |
Chaque execve et chaque mmap exécutable a été vu, y compris un script #! et son
interprète, et le dlopen de Python ; toutes les empreintes ont abouti. Mais 7,4 ms par
fichier empreinté en moyenne : sans politique IMA, bpf_ima_file_hash recalcule tout le
fichier à chaque appel. Aucun mprotect exécutable n’a été observé.
5.2 Avec la politique de mesure d’IMA, le coût disparaît
Une politique IMA de mesure seulement — elle calcule et journalise, elle ne refuse rien :
measure func=BPRM_CHECK
measure func=MMAP_CHECK mask=MAY_EXEC
Temps par execve | sans rien | IMA mesure seule | IMA mesure + programme BPF |
|---|---|---|---|
/bin/true | 2,65 ms | 2,65 ms | 2,69 ms |
air-bundle (Air) | 2,16 ms | 2,25 ms | 2,17 ms |
L’empreinte lue par le programme coûte alors ≈ 2 µs à l’execve et ≈ 2,7 µs au mmap :
IMA calcule chaque fichier une fois par version (28 mesures au total, une seule pour le
binaire Air exécuté 500 fois), et bpf_ima_file_hash relit son cache.
5.3 Le cache suit les modifications
Un binaire copié, exécuté, puis modifié (un octet ajouté) et réexécuté : IMA consigne une
seconde empreinte, identique à celle de sha256sum. Le cache ne valide pas une ancienne
version.
5.4 La machine a été rendue
Redémarrage normal : retour à current/, liste de modules d’origine, aucune politique IMA
(une seule mesure, celle du démarrage), copie new/ supprimée, ligne de démarrage d’origine
inchangée. Restent installés sur helium : bpftool et libbpf-dev.
5.5 La même mesure sur x86_64 — hydrogen
hydrogen (x86_64, Ubuntu, noyau 7.0.0-34-generic, exécuteur de CI air-x86_64), autorisé par
le BDFL à condition de le sortir des exécuteurs pendant l’essai : service de l’exécuteur
arrêté et désactivé (GitHub l’a vu hors ligne), puis réactivé à la fin (GitHub l’a revu en
ligne, étiquette air-x86_64). Aucun job n’était en cours.
Sans toucher à son démarrage : kexec -s du même noyau avec la ligne de démarrage d’origine
plus lsm=…,ipe,bpf — rien n’est écrit dans GRUB ni sous /boot ; un redémarrage normal l’a
ramené à l’identique. Le binaire Air mesuré est air-bundle, construit et marqué sur carbon.
Temps par execve | sans rien | programme, sans cache | IMA mesure seule | IMA mesure + programme |
|---|---|---|---|---|
/bin/true | 0,91 ms | 11,0 ms | 0,83 ms | 0,89 ms |
air-bundle (Air) | 0,59 ms | 32,9 ms | 0,69 ms | 0,63 ms |
Sans cache : 3,4 ms par empreinte à l’execve, 4,2 ms au mmap. Avec la mesure IMA : ≈ 0,3 µs
et ≈ 1,5 µs. Même couverture qu’en aarch64 ; un fichier modifié reçoit une nouvelle
empreinte, identique à sha256sum. hydrogen rendu : ligne de démarrage d’origine, modules
d’origine, une seule mesure IMA. Reste installé : libbpf-dev.
5.6 Ce que cela établit — et ce qui n’est pas établi
Établi, sur aarch64 et x86_64 : un seuil BPF LSM qui compare l’empreinte de chaque exécutable et de
chaque bibliothèque projetée en mémoire exécutable à une table tenue par Air — la voie (ii)
d’ADR-189 D4 — couvre execve, les interprètes de #! et dlopen, pour un surcoût
négligeable, à condition qu’une politique IMA de mesure tienne le cache des empreintes.
Il ne demande que les clés Ed25519 d’Air, ni fs-verity ni image vérifiée.
Pas établi :
- le refus lui-même : le programme mesuré ne refuse rien. Un refus sur
mmap_filetouche le chargeur et lalibcde tout programme glibc : le mode transitoire d’ADR-188 D4 devra être éprouvé avant tout refus réel ; - la table : comment
air-bundlel’alimente, qui peut l’écrire, ce qui se passe au redémarrage (la table et le programme doivent être posés avant le premier programme d’utilisateur) ; - la taille du journal IMA : une entrée par version de fichier exécuté, en mémoire ;
- le coût du premier calcul d’un gros fichier, qui se paie une fois par version.
6. Ce que cette note ne décide pas
Tranché depuis : le BDFL a retenu la voie (ii) — BPF LSM, politique de mesure d’IMA, table d’empreintes tenue par Air — le 2026-09-28 (amendements d’ADR-188 D3 et d’ADR-189 D4). Cette note ne décide ni de la forme de la table, ni du passage au refus réel.
7. Phase 2 (2026-10-13) — ce que la phase 1 laissait « pas établi »
Mesuré sur helium (aarch64, 7.0.0-1020-raspi, bpf actif), la politique de mesure posée à
chaud puis la machine rendue par un redémarrage. Les coûts par empreinte de la phase 1 n’ont
pas été remesurés : ils tiennent.
7.1 La taille du journal : elle croît par VERSION de fichier, jamais par exécution
| Expérience | Résultat |
|---|---|
| 500 exécutions du même fichier | +0 entrée |
| 200 fichiers distincts, une exécution chacun | +200 entrées, exactement |
| Un fichier modifié d’un octet, réexécuté | +1 entrée (deux au total pour ce chemin) |
| Coût par entrée — journal binaire | 106 octets |
Coût par entrée — mémoire noyau (SUnreclaim) | 389 octets |
Le contrôle, sans lequel le second chiffre aurait été faux de 3×. Créer les 200 fichiers
sans les exécuter donne 0 entrée et pourtant +48 Kio de SUnreclaim — le coût des
inodes, pas celui d’IMA. Seule la seconde moitié de l’expérience (les exécuter) attribue à la
mesure ses +76 Kio. Mesurer la charge d’un bout à l’autre aurait imputé les deux à IMA.
Conséquence de dimensionnement : 10 000 versions distinctes ≈ 1 Mio de journal et ≈ 3,9 Mio de mémoire noyau, et ce journal ne se purge pas avant le redémarrage. Le cas qui mord n’est donc pas la machine de bureau — elle réexécute les mêmes fichiers — mais l’exécuteur de CI, qui fabrique des binaires neufs à chaque build.
7.2 Le coût du premier calcul : ~7,5 ms par Mio (aarch64)
| Taille du fichier | 1er execve | 2e execve |
|---|---|---|
| 168 Kio | < 10 ms | < 10 ms |
| 2,1 Mio | 0,01 s | 0,00 s |
| 20 Mio | 0,15 s | 0,00 s |
Une fois par version, puis plus rien. À titre d’ordre de grandeur, le lot de binaires de test du dépôt relevé le même jour sur speedy — 43 binaires, 0,8 Gio, médiane 8,4 Mio, maximum 71,2 Mio — coûterait ≈ 6 s d’empreintes à sa première exécution, sur des builds qui durent une demi-heure.
Pas mesuré : le même débit sur x86_64, où le SHA-256 est plus rapide — la phase 1 donnait un rapport d’environ deux entre les deux architectures, mais ce rapport-ci n’a pas été établi.
7.3 La persistance : la politique ne survit pas au redémarrage
CONFIG_IMA_READ_POLICY et CONFIG_IMA_WRITE_POLICY ne sont pas compilés sur ce noyau.
Trois conséquences, toutes vérifiées :
- la politique ne s’écrit qu’une fois par amorçage ;
- elle est en écriture seule : rien ne peut relire ce qu’elle couvre ;
- le fichier
/sys/kernel/security/ima/policydisparaît du répertoire dès qu’elle est posée — constaté, non supposé.
Le point 3 n’était pas attendu, et il est utile : il rend la pose idempotente par une simple
ConditionPathExists, de sorte qu’un systemctl restart est sauté (« unmet condition check »)
au lieu d’être tenté puis échoué.
Le geste retenu (décision du BDFL du 2026-10-13, voie « geste à l’amorçage ») est une unité
oneshot ordonnée avant le lanceur, livrée en exemple comme les autres unités —
crates/air-service/units/air-ima-policy.service et ima-policy — et posée à la main. Éprouvée
sur helium par un redémarrage complet : unité active (exited), code 0, et le compteur passe de
1 à 358, dont 122 entrées de binaires (BPRM_CHECK) et 236 de bibliothèques
(MMAP_CHECK mask=MAY_EXEC) — les deux règles mordent, et systemd-executor figure parmi les
mesurés, donc la politique est posée assez tôt pour couvrir les services du démarrage.
Pas éprouvé : l’ordre Before=air-launchd.service, helium ne portant aucun paquet Air. Il le
sera à la pose sur carbon et neon.
7.4 La garde : le seuil refuse de se poser sans cache
La note de phase 1 qualifiait l’absence de politique de prohibitive ; rien ne la constatait.
AirExecutionThresholdManager::load la refuse désormais
(ThresholdLoadError::ImaMeasurementsAbsent), comme il refusait déjà un bpf inactif, et le
lanceur l’écrit au journal par le même chemin que ses autres refus.
Le témoin, et sa limite — dite ici parce qu’elle ne peut pas être levée. La garde lit
/sys/kernel/security/ima/runtime_measurements_count : il vaut 1 quand aucune politique
n’alimente le journal (le seul boot_aggregate), et davantage dès qu’une politique mesure. Cela
établit qu’une politique mesure — pas ce qu’elle couvre, puisque la politique active est
illisible (point 2 ci-dessus). Une politique qui ne mesurerait ni l’exécution ni la projection
exécutable satisferait donc la garde en laissant le cache froid. Sur une machine Air, la
politique posée est celle que livre ce dépôt, dont le contenu est versionné.