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

Spec couche 0 — Famille security

Spécification technique — Version 1.0

Vue d’ensemble de la famille

Le module air-sys-syscall::security expose les primitives de sandboxing kernel : seccomp-BPF (filtrage de syscalls) et Landlock (filtrage d’accès filesystem). Ces deux mécanismes sont au cœur du modèle de sécurité Air (ADR-010 : entitlements et sandbox capability-based).

Périmètre de la famille.

Trois sous-systèmes distincts :

  1. seccomp-BPF : filtrage des syscalls qu’un thread peut exécuter.
  2. Landlock : filtrage d’accès au filesystem.
  3. Capabilities : déjà couvertes dans la famille process (capget, capset).

Position dans le modèle de sécurité Air.

Le launcher Air (couche 5, ADR-010) applique au démarrage de chaque application :

  • Les capabilities selon les entitlements.
  • Les filtres Landlock pour restreindre l’accès filesystem.
  • Les filtres seccomp pour restreindre les syscalls.
  • L’isolation par namespaces.

La couche 0 fournit les primitives ; la couche 5 les orchestre.

Caractéristiques transverses.

  1. Opérations privilégiées ou contraintes. Beaucoup demandent CAP_SYS_ADMIN ou l’activation préalable de no_new_privs.

  2. Irréversibilité. Les restrictions de sécurité sont strictement croissantes : une fois appliquées, elles ne peuvent pas être affaiblies.

  3. Couche 0 = primitives seules. Pour seccomp-BPF, la couche 0 n’expose pas d’API déclarative ni de compilateur de filtres : elle charge un programme BPF déjà formé (cf. Q4, sous-section 1). L’API déclarative SeccompFilter et sa compilation vivent en couche 1. De même, les helpers Landlock de haut niveau (combinaison create/add/restrict, masquage d’ABI) relèvent de la couche 1 ; la couche 0 n’expose que les primitives Landlock.

  4. Pas de wrapper pour AppArmor / SELinux. Ces mécanismes sont gérés au niveau distribution.

Sous-section 1 : seccomp

Périmètre couche 0 : la primitive seulement, pas le compilateur de filtres

Décision d’architecture (Q4, validée le 2026-05-31). La rédaction initiale de cette spec plaçait en couche 0 une API déclarative (SeccompFilter, SeccompRule, SeccompAction…) compilée en BPF en interne. C’est trop pour la couche 0 : compiler un filtre déclaratif en bytecode BPF est de la logique (encodage d’instructions sock_filter, calcul des offsets de saut, prise en compte de l’architecture), pas un wrapper de syscall. Conformément à la raison d’être de la couche 0 (« abstraire sans cacher », façade fine sur le kernel), la couche 0 n’expose que la primitive : charger dans le kernel un programme BPF déjà compilé, fourni par l’appelant. L’API déclarative et son compilateur (SeccompFilter, SeccompRule, SeccompAction, SyscallArgumentCondition, ConditionOp, SyscallNumber) sont spécifiés et implémentés en couche 1, et sortent du périmètre de la couche 0.

Frontière (Q4, actée — confirmée par le code livré, 2026-06-14). La couche 0 expose uniquement le primitif seccomp_set_mode_filter(&SockFprog, SeccompFilterFlags) — où SockFprog emprunte une tranche de sock_filter déjà compilée par l’appelant. La compilation déclarative (règles de filtre → bytecode cBPF sock_filter) est de la logiquecouche 1 (cohérent ADR-021 : la couche 0 ne fait pas de logique). Aucun bpf_compiler/type déclaratif SeccompFilter en couche 0. (Clôture de la question Q4 de QUESTIONS-implementation.md : le code mergé implémente exactement ce primitif.)

Types : un programme BPF déjà formé

Ces types sont purs (aucun syscall) et vivent dans air-sys-types::security.

#![allow(unused)]
fn main() {
/// Une instruction de filtre BPF classique — layout exact de `struct sock_filter`.
#[repr(C)]
pub struct SockFilter {
    pub code: u16,
    pub jt: u8,
    pub jf: u8,
    pub k: u32,
}

/// Programme BPF complet à charger — équivalent de `struct sock_fprog`.
/// Emprunte une tranche d'instructions appartenant à l'appelant (zéro alloc).
pub struct SockFprog<'a> { /* len: u16, filter: *const SockFilter, marqueur 'a */ }

impl<'a> SockFprog<'a> {
    /// Construit un programme à partir d'une tranche d'instructions.
    ///
    /// # Errors
    /// `EINVAL` si la tranche est vide ou dépasse `BPF_MAXINSNS` (4096).
    pub fn new(instructions: &'a [SockFilter]) -> Result<Self, Errno>;
}
}

Le programme BPF lui-même est produit ailleurs (compilateur déclaratif de couche 1, ou tout autre moyen) et passé ici sous forme de tranche. La couche 0 ne l’inspecte pas et ne le transforme pas : elle le charge tel quel.

seccomp_set_mode_filter

#![allow(unused)]
fn main() {
pub unsafe fn seccomp_set_mode_filter(
    prog: &SockFprog<'_>,
    flags: SeccompFilterFlags,
) -> Result<(), Errno>;

bitflags! {
    pub struct SeccompFilterFlags: u32 {
        const TSYNC = 1;
        const LOG = 2;
        const SPEC_ALLOW = 4;
        const NEW_LISTENER = 8;
        const TSYNC_ESRCH = 16;
        const WAIT_KILLABLE_RECV = 32;
    }
}
}

Charge le programme BPF prog dans le kernel pour le thread courant (seccomp(SECCOMP_SET_MODE_FILTER, flags, prog)).

unsafe — préconditions (# Safety) :

  • l’appelant doit avoir appelé set_no_new_privs() (famille process) ou détenir CAP_SYS_ADMIN ;
  • le programme BPF doit autoriser tous les syscalls réellement utilisés par le runtime Rust et par le code exécuté après le chargement, faute de quoi le thread/processus sera tué ;
  • l’opération est irréversible et strictement croissante (on ne peut que durcir).

TSYNC applique le filtre à tous les threads du processus. NEW_LISTENER retourne un descripteur de notification (seccomp_unotify) — exploité en couche 1.

EINTR remonté tel quel, jamais de retry automatique (ADR-021 conv. 2).

seccomp_set_mode_strict

#![allow(unused)]
fn main() {
pub fn seccomp_set_mode_strict() -> Result<(), Errno>;
}

Active le mode strict (SECCOMP_SET_MODE_STRICT) : seuls read, write, _exit/exit_group et sigreturn restent autorisés. Aucun programme à fournir, aucune compilation — d’où l’absence d’unsafe. Irréversible.

Performance. Chargement d’un filtre : ~50-200 µs. Coût par syscall ensuite : ~50-100 ns pour un filtre simple.

Tests délicats. Une violation seccomp tue typiquement le thread/processus ; les tests s’exécutent dans des sous-processus isolés (fork + observation du statut via waitid), avec skip explicite si l’environnement n’autorise pas no_new_privs.

Prérequis prctl_no_new_privs

Couvert par la famille process (prctl), rappelé ici car prérequis à seccomp sans privilège. Irréversible.

Sous-section 2 : Landlock

Landlock est un mécanisme Linux moderne pour restreindre les accès filesystem au niveau de chemins, sans privilèges. Disponible depuis Linux 5.13.

L’API est conçue autour de “rulesets” : des ensembles de règles qui définissent quels chemins sont accessibles avec quelles permissions.

landlock_create_ruleset

#![allow(unused)]
fn main() {
pub fn landlock_create_ruleset(
    handled_access: LandlockAccessFs,
) -> Result<LandlockRuleset, Errno>;

pub struct LandlockRuleset { /* possède OwnedFd interne */ }

impl LandlockRuleset {
    pub fn add_rule_path_beneath(
        &mut self,
        path: BorrowedFd<'_>,
        allowed_access: LandlockAccessFs,
    ) -> Result<(), Errno>;
    
    pub fn restrict_self(&self) -> Result<(), Errno>;
    pub fn as_fd(&self) -> BorrowedFd<'_>;
}

bitflags! {
    pub struct LandlockAccessFs: u64 {
        const EXECUTE = 1 << 0;
        const WRITE_FILE = 1 << 1;
        const READ_FILE = 1 << 2;
        const READ_DIR = 1 << 3;
        const REMOVE_DIR = 1 << 4;
        const REMOVE_FILE = 1 << 5;
        const MAKE_CHAR = 1 << 6;
        const MAKE_DIR = 1 << 7;
        const MAKE_REG = 1 << 8;
        const MAKE_SOCK = 1 << 9;
        const MAKE_FIFO = 1 << 10;
        const MAKE_BLOCK = 1 << 11;
        const MAKE_SYM = 1 << 12;
        const REFER = 1 << 13;       // Landlock v2 (Linux 5.19)
        const TRUNCATE = 1 << 14;    // Landlock v3 (Linux 6.2)
        const IOCTL_DEV = 1 << 15;   // Landlock v5 (Linux 6.10)
    }
}
}

Placement (Q1, validée le 2026-05-31). LandlockRuleset est un type RAII dont les méthodes (add_rule_path_beneath, restrict_self) et le Drop appellent des syscalls Landlock. Il réside donc dans air-sys-syscall::security, et non dans air-sys-types — même règle que Mapping côté famille mem : un type qui appelle un syscall vit dans la crate des wrappers, jamais dans la crate des types purs. Seuls les types sans syscall (LandlockAccessFs) restent dans air-sys-types.

Préconditions.

handled_access indique quelles permissions ce ruleset va gérer.

Les permissions disponibles dépendent de la version d’ABI Landlock du kernel.

Décision (Q6, validée le 2026-05-31) : pas de masquage automatique en couche 0. landlock_supported_abi() se contente de rapporter la version d’ABI supportée par le kernel. landlock_create_ruleset / add_rule_path_beneath / restrict_self ne masquent pas silencieusement les bits d’accès non supportés : si l’appelant passe un bit que le kernel ne connaît pas, l’erreur kernel (EINVAL) est remontée telle quelle. Masquer en douce serait un comportement « magique », contraire au principe « abstraire sans cacher ». C’est à l’appelant — ou à un helper de couche 1 (landlock_restrict_to_paths) — d’interroger landlock_supported_abi() puis de masquer les bits selon l’ABI cible.

Pattern d’usage typique.

#![allow(unused)]
fn main() {
let mut ruleset = landlock_create_ruleset(
    LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR | LandlockAccessFs::EXECUTE,
)?;

let usr_fd = openat(DirFd::Cwd, c"/usr", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
    usr_fd.as_fd(),
    LandlockAccessFs::READ_FILE | LandlockAccessFs::EXECUTE,
)?;

let home_fd = openat(DirFd::Cwd, c"/home/user", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
    home_fd.as_fd(),
    LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR,
)?;

set_no_new_privs()?;
ruleset.restrict_self()?;
}

Particularités.

  1. Irréversibilité. restrict_self est définitif.
  2. Cumul de rulesets. Plusieurs rulesets successifs ajoutent des restrictions.
  3. NO_NEW_PRIVS prérequis sauf si CAP_SYS_ADMIN.
  4. Sémantique “path_beneath”. Une règle s’applique à un chemin et tous ses descendants.
  5. Versions ABI. Détecter via landlock_supported_abi().

Performance.

Création : ~10-50 µs. Ajout de règle : ~5-10 µs. restrict_self : ~50-100 µs. Coût par accès filesystem ensuite : ~100-500 ns.

Helpers haut niveau

En couche 0, seule la requête de version d’ABI est exposée — une primitive pure de lecture, sans masquage (cf. Q6) :

#![allow(unused)]
fn main() {
/// Rapporte la version d'ABI Landlock supportée par le kernel courant.
pub fn landlock_supported_abi() -> Result<u32, Errno>;
}

Déplacé en couche 1 (Q4/Q6). Le helper landlock_restrict_to_paths(paths) — qui combine create_ruleset + plusieurs add_rule_path_beneath + restrict_self en un appel, et qui peut masquer les bits selon l’ABI — est de la logique de commodité, pas une primitive syscall. Il est donc spécifié en couche 1, aux côtés de l’API seccomp déclarative. La couche 0 n’expose que les briques (landlock_create_ruleset, add_rule_path_beneath, restrict_self, landlock_supported_abi) ; c’est typiquement le launcher Air, via ce helper de couche 1, qui appliquera les entitlements filesystem d’une application.

Récapitulatif de la famille security

Fonctions exposées :

CatégorieFonctions principales
seccompseccomp_set_mode_filter, seccomp_set_mode_strict
Landlocklandlock_create_ruleset, LandlockRuleset::add_rule_path_beneath, LandlockRuleset::restrict_self, LandlockRuleset::as_fd, landlock_supported_abi

Total : ~6 fonctions publiques principales en couche 0. Reportés en couche 1 (hors de ce périmètre) : l’API seccomp déclarative (SeccompFilter / SeccompRule / SeccompAction / … + compilateur BPF) et le helper landlock_restrict_to_paths.

Syscalls listés mais non-wrappés en phase 0 :

  • bpf : API générique pour les programmes BPF. Avancé.
  • perf_event_open : profiling et monitoring. Hors phase 0.
  • keyctl, add_key, request_key : keyring kernel. À évaluer.
  • pkey_alloc, pkey_free, pkey_mprotect : memory protection keys. Marginal.

Répartition des types entre les deux crates

Dans air-sys-types (purs, sans syscall)

  • SockFilter, SockFprog — programme BPF déjà formé.
  • SeccompFilterFlags — drapeaux de chargement seccomp.
  • LandlockAccessFs — bits d’accès filesystem.

Dans air-sys-syscall::security (RAII appelant des syscalls)

  • LandlockRuleset — possède un OwnedFd ; ses méthodes et son Drop appellent des syscalls. Ne vit donc pas dans air-sys-types (décision Q1 ci-dessus ; même règle que Mapping côté mem).

Reportés en couche 1 (hors couche 0, décision Q4)

  • API seccomp déclarative : SeccompFilter, SeccompRule, SeccompAction, SyscallArgumentCondition, ConditionOp, SyscallNumber, et le compilateur déclaratif → BPF.

Décisions de fond émergées dans la famille security

1. Couche 0 = primitive seccomp seulement (Q4).

La couche 0 n’expose ni la construction ni la compilation de filtres : elle charge un programme BPF déjà formé (seccomp_set_mode_filter) ou active le mode strict (seccomp_set_mode_strict). L’API déclarative confortable (qui « évite les bugs courants ») est réelle et utile — mais c’est de la logique, donc spécifiée en couche 1, au-dessus de cette primitive.

2. Helpers haut niveau → couche 1 (Q4/Q6).

landlock_restrict_to_paths (combinaison create/add/restrict + masquage d’ABI éventuel) couvre ~90 % des usages, mais reste de la commodité construite sur les primitives : il vit en couche 1. La couche 0 ne fournit que les briques Landlock et la requête landlock_supported_abi (sans masquage, Q6).

2 bis. Types RAII à syscall hors air-sys-types (Q1).

LandlockRuleset (comme Mapping côté mem) appelle des syscalls dans ses méthodes et son Drop ; il réside dans air-sys-syscall::security, jamais dans la crate des types purs.

3. Pas d’API AppArmor / SELinux.

Ces frameworks sont configurés au niveau distribution.

4. Détection runtime de la version Landlock ABI.

Permet aux applications de s’adapter sans recompilation.


Licence du document : MPL 2.0 Statut : Spécification technique du module air-sys-syscall::security (couche 0).