gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1389Shell 137Markdown 80Rust 802602 Definiciones

⎇ e76d90cc60eca9ff499aeeb82333e5403604b351 crates/takana-core/src/setuid.rs Rust · 382 líneas · 17.3 KB Historia Quién tocó qué crudo

paso 2: el bit de privilegio se declara y se verifica — y `doas` queda solo (SDD 28 §5.11) e76d90cc6 · Sergio ·

1//! `[[setuid]]` — **el bit de privilegio: quién lo pone, quién lo verifica, y qué significa que el
2//! hash no lo cubra.** Es la respuesta a las tres preguntas que el SDD 28 §5.11 dejó «anotado, sin
3//! decidir» el 2026-09-21.
4//!
5//! ## El hueco, medido (2026-09-22)
6//!
7//! **26 binarios** de las imágenes son setuid o setgid en cualquier otra distro y acá salen todos
8//! `r-xr-xr-x`: `sudo`, `doas`, `su`, `login`, `passwd`, `chage`, `chfn`, `chsh`, `expiry`,
9//! `gpasswd`, `newgrp`, `sg`, `newuidmap`, `newgidmap`, `mount`, `umount`, `sulogin`, `wall`,
10//! `unix_chkpwd`, `dbus-daemon-launch-helper`, `fusermount3`, `ping`, `ssh-agent`, `bwrap`… El
11//! síntoma no menciona nunca el bit (`Cannot change ID to root.`, §5.11) y **hoy es LATENTE**: el
12//! instalador sólo pone contraseña de root, así que no hay un humano no-root que lo choque. El día
13//! que lo haya, fallan a la vez el cambio de contraseña, el `mount` de usuario, FUSE, `ping` y
14//! —peor— `unix_chkpwd`, que es con lo que PAM verifica una contraseña: los greeters de los cuatro
15//! escritorios no podrían autenticar a nadie.
16//!
17//! ## 1 · Quién lo pone: **el ensamblado de la imagen, no el artefacto**
18//!
19//! Y no es una preferencia, son dos hechos que lo cierran:
20//!
21//! - **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
22//!   (`mode & 0o111`, `hash.rs:79`). Un artefacto con setuid y otro sin él tienen el MISMO hash ⇒
23//!   dejarlo ahí es privilegio no cubierto por el contrato que protege todo lo demás.
24//! - **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store:
25//!   poner el bit en la imagen lo pondría en el artefacto compartido, y al revés. Un `chmod u+s` en
26//!   un rootfs de pruebas dejaría setuid el artefacto que usan todas las demás imágenes.
27//!
28//! ⇒ El bit se aplica al ARMAR la imagen, **rompiendo el enlace duro** del fichero afectado
29//! ([`aplicar`]), desde una declaración que vive en la receta y está versionada en git. La
30//! declaración es metadato —fuera de `hash_inputs`, como `license`, `slots`, `[[service]]` y
31//! `[[user]]`—, así que se puebla en recetas YA SELLADAS sin mover un solo `ArtifactHash`.
32//!
33//! ## 2 · Quién lo verifica: las DOS direcciones, y la segunda es la que importa
34//!
35//! - *lo declarado está*: cada ruta existe en el rootfs y queda con el modo pedido.
36//! - **lo que está, está declarado**: ningún fichero del rootfs tiene setuid/setgid sin declararlo
37//!   ([`sin_declarar`]). Esta dirección convierte una lista en un INVARIANTE, y es la que ninguna
38//!   distro clásica puede sostener barata: acá el rootfs sale de artefactos inmutables, así que un
39//!   bit que aparece sin que nadie lo declare es, por construcción, alguien pisando la imagen.
40//!
41//! ## 3 · Qué significa que el hash no lo cubra
42//!
43//! Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
44//! sigue afirmando exactamente lo que afirmaba —estos bytes, con este bit de ejecución— y no se
45//! toca. Lo que cambia es que el privilegio pasa a tener su propio registro (la receta, en git) y su
46//! propia verificación (las dos direcciones de arriba). Lo que NO se puede hacer es guardarlo en el
47//! store y creer que el hash lo protege.
48//!
49//! ## Lo que este módulo NO hace
50//!
51//! **No hace `chown`.** No puede: el ensamblado corre sin root. La propiedad `root:root` la produce
52//! `install-image.sh` con `unshare -r`, que mapea el uid del constructor a 0 ⇒ un fichero nuestro
53//! con `u+s` sale setuid ROOT dentro de la imagen. Por eso [`aplicar`] **devuelve el uid de cada
54//! fichero** y quien llama lo compara con el suyo: un setuid de otro dueño no se convierte en root
55//! con ese mapeo, y sería un bit inútil con forma de bit puesto. El uid lo reporta este módulo y el
56//! aviso lo da el llamador, para no meterle `libc` a la caja de tipos compartidos por un `geteuid`.
57
58use serde::{Deserialize, Serialize};
59
60/// Una ruta del rootfs que necesita el bit, con su justificación.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct Setuid {
63    /// Ruta RELATIVA a la raíz de la imagen, sin `/` inicial: `usr/bin/doas`.
64    pub path: String,
65    /// Modo octal completo, `"4755"` por defecto. Tiene que llevar setuid (`4000`) o setgid
66    /// (`2000`): si no, esta declaración no es lo que dice ser y se rechaza.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub mode: Option<String>,
69    /// Dueño esperado DENTRO de la imagen, `"root:root"` por defecto. Es documentación verificable,
70    /// no una acción: el `chown` lo hace el mapeo de `unshare -r` (ver el encabezado del módulo).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub owner: Option<String>,
73    /// **Por qué este binario necesita privilegio.** Obligatorio a propósito: un setuid root que no
74    /// sabe explicarse no se lo gana. Es lo que un auditor lee, y lo que hace revisable la lista.
75    pub why: String,
76}
77
78impl Setuid {
79    pub fn mode_octal(&self) -> crate::Result<u32> {
80        let m = self.mode.as_deref().unwrap_or("4755");
81        let v = u32::from_str_radix(m, 8)
82            .map_err(|_| crate::Error::Recipe(format!("setuid {}: modo `{m}` no es octal", self.path)))?;
83        Ok(v)
84    }
85
86    pub fn owner(&self) -> &str {
87        self.owner.as_deref().unwrap_or("root:root")
88    }
89
90    pub fn validate(&self) -> crate::Result<()> {
91        let bad = |m: String| Err(crate::Error::Recipe(m));
92        if self.path.starts_with('/') || self.path.is_empty() {
93            return bad(format!(
94                "setuid `{}`: la ruta es RELATIVA a la imagen (`usr/bin/doas`, no `/usr/bin/doas`)",
95                self.path
96            ));
97        }
98        // `..` en una ruta de instalación sale del rootfs. No se normaliza: se rechaza.
99        if self.path.split('/').any(|c| c == ".." || c == ".") {
100            return bad(format!("setuid `{}`: la ruta no puede llevar `.` ni `..`", self.path));
101        }
102        let m = self.mode_octal()?;
103        if m & 0o6000 == 0 {
104            return bad(format!(
105                "setuid `{}`: el modo {:o} no lleva setuid (4000) ni setgid (2000) — entonces esta \
106                 declaración no hace falta",
107                self.path, m
108            ));
109        }
110        // Un setuid root escribible por cualquiera es una puerta abierta, no un permiso.
111        if m & 0o022 != 0 {
112            return bad(format!(
113                "setuid `{}`: modo {:o} escribible por grupo u otros — con el bit puesto eso es \
114                 root para cualquiera",
115                self.path, m
116            ));
117        }
118        if m & 0o111 == 0 {
119            return bad(format!("setuid `{}`: modo {:o} sin bit de ejecución", self.path, m));
120        }
121        if self.why.trim().is_empty() {
122            return bad(format!("setuid `{}`: falta `why` — un setuid sin motivo no se pone", self.path));
123        }
124        if !self.owner().contains(':') {
125            return bad(format!("setuid `{}`: `owner` va como `usuario:grupo`", self.path));
126        }
127        Ok(())
128    }
129}
130
131/// Lo que sólo se ve al juntar varias recetas: dos paquetes que reclaman la MISMA ruta. En una
132/// imagen eso es una carrera —gana el último que hidrate— y el modo final no lo decide nadie.
133pub fn validate_all(decls: &[(String, Setuid)]) -> crate::Result<()> {
134    for (_, s) in decls {
135        s.validate()?;
136    }
137    let mut vistas: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
138    for (receta, s) in decls {
139        if let Some(otra) = vistas.insert(&s.path, receta) {
140            if otra != receta {
141                return Err(crate::Error::Recipe(format!(
142                    "setuid `{}`: lo declaran DOS recetas ({otra} y {receta}) — el modo final lo \
143                     decidiría el orden de hidratación",
144                    s.path
145                )));
146            }
147        }
148    }
149    Ok(())
150}
151
152/// Qué pasó con cada declaración al aplicarla.
153#[derive(Debug, PartialEq)]
154pub enum Resultado {
155    /// Se puso el bit (y se rompió el enlace duro si hacía falta).
156    Puesto { rompio_hardlink: bool },
157    /// Ya estaba con el modo pedido: aplicar dos veces no hace nada (idempotente).
158    YaEstaba,
159    /// La ruta no existe en el rootfs. Es un ERROR: una declaración que apunta a un binario que la
160    /// imagen no trae es exactamente la trampa de [[catalogo-no-es-imagen]].
161    NoExiste,
162    /// Existe pero no es un fichero regular (symlink a otro artefacto, directorio…).
163    NoEsFichero,
164}
165
166/// Aplica las declaraciones sobre `rootfs`. Devuelve `(receta, decl, resultado, uid del fichero)`.
167///
168/// **Rompe el enlace duro** antes de tocar el modo: sin eso el `chmod` viajaría al artefacto del
169/// store, que es compartido por todas las imágenes (ver el encabezado del módulo). La copia se hace
170/// al mismo directorio y se `rename`a encima, que es atómico y deja el artefacto intacto con un link
171/// menos — el mismo patrón que [`crate::user::escribir_rompiendo_hardlink`].
172pub fn aplicar(
173    rootfs: &std::path::Path,
174    decls: &[(String, Setuid)],
175) -> crate::Result<Vec<(String, Setuid, Resultado, u32)>> {
176    #[cfg(unix)]
177    use std::os::unix::fs::{MetadataExt, PermissionsExt};
178    let mut out = Vec::new();
179    for (receta, s) in decls {
180        let p = rootfs.join(&s.path);
181        let meta = match std::fs::symlink_metadata(&p) {
182            Err(_) => {
183                out.push((receta.clone(), s.clone(), Resultado::NoExiste, 0));
184                continue;
185            }
186            Ok(m) => m,
187        };
188        if !meta.is_file() {
189            out.push((receta.clone(), s.clone(), Resultado::NoEsFichero, 0));
190            continue;
191        }
192        let modo_pedido = s.mode_octal()?;
193        #[cfg(unix)]
194        {
195            // El `chown` lo hace el mapeo de `unshare -r` del ensamblado, y ese mapeo sólo convierte
196            // en root al uid QUE CORRE ⇒ el llamador compara el uid FINAL con el suyo. Se reporta el
197            // de DESPUÉS de aplicar, no el de antes: romper el enlace duro crea un inodo nuevo cuyo
198            // dueño es quien corre, y ahí está la mitad buena del diseño — el bit sale utilizable
199            // incluso partiendo de un artefacto de otro dueño (en este store hay artefactos del uid
200            // 1000, del worker, que dentro de la jaula se ven `nobody`).
201            if meta.permissions().mode() & 0o7777 == modo_pedido {
202                out.push((receta.clone(), s.clone(), Resultado::YaEstaba, meta.uid()));
203                continue;
204            }
205            let rompe = meta.nlink() > 1;
206            if rompe {
207                let dir = p.parent().unwrap_or(std::path::Path::new("."));
208                let tmp = dir.join(format!(
209                    ".{}.setuid-tmp",
210                    p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero")
211                ));
212                std::fs::copy(&p, &tmp)?;
213                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(modo_pedido))?;
214                std::fs::rename(&tmp, &p)?;
215            } else {
216                std::fs::set_permissions(&p, std::fs::Permissions::from_mode(modo_pedido))?;
217            }
218            let uid_final = std::fs::symlink_metadata(&p).map(|m| m.uid()).unwrap_or(0);
219            out.push((receta.clone(), s.clone(), Resultado::Puesto { rompio_hardlink: rompe }, uid_final));
220        }
221        #[cfg(not(unix))]
222        out.push((receta.clone(), s.clone(), Resultado::NoEsFichero, 0));
223    }
224    Ok(out)
225}
226
227/// La segunda dirección de la verificación: ficheros del rootfs con setuid/setgid que NADIE declaró.
228/// Devuelve `(ruta relativa, modo)`. Un rootfs recién armado tiene que dar la lista vacía.
229pub fn sin_declarar(
230    rootfs: &std::path::Path,
231    decls: &[(String, Setuid)],
232) -> crate::Result<Vec<(String, u32)>> {
233    #[cfg(unix)]
234    use std::os::unix::fs::PermissionsExt;
235    let declaradas: std::collections::HashSet<&str> = decls.iter().map(|(_, s)| s.path.as_str()).collect();
236    let mut out = Vec::new();
237    let mut pila = vec![rootfs.to_path_buf()];
238    while let Some(d) = pila.pop() {
239        let it = match std::fs::read_dir(&d) {
240            Ok(i) => i,
241            Err(_) => continue,
242        };
243        for e in it.flatten() {
244            let p = e.path();
245            let meta = match std::fs::symlink_metadata(&p) {
246                Ok(m) => m,
247                Err(_) => continue,
248            };
249            if meta.file_type().is_symlink() {
250                continue;
251            }
252            if meta.is_dir() {
253                pila.push(p);
254                continue;
255            }
256            #[cfg(unix)]
257            {
258                let m = meta.permissions().mode() & 0o7777;
259                if m & 0o6000 != 0 {
260                    let rel = p.strip_prefix(rootfs).unwrap_or(&p).to_string_lossy().to_string();
261                    if !declaradas.contains(rel.as_str()) {
262                        out.push((rel, m));
263                    }
264                }
265            }
266        }
267    }
268    out.sort();
269    Ok(out)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn doas() -> Setuid {
277        Setuid {
278            path: "usr/bin/doas".into(),
279            mode: None,
280            owner: None,
281            why: "es el único camino de escalada de la imagen".into(),
282        }
283    }
284
285    #[test]
286    fn defaults_4755_root_root() {
287        let s = doas();
288        assert_eq!(s.mode_octal().unwrap(), 0o4755);
289        assert_eq!(s.owner(), "root:root");
290        s.validate().unwrap();
291    }
292
293    #[test]
294    fn rechaza_lo_que_no_es_una_declaracion_de_privilegio() {
295        // sin el bit: la declaración no hace falta y engaña al que la lee
296        let mut s = doas();
297        s.mode = Some("0755".into());
298        assert!(s.validate().unwrap_err().to_string().contains("no lleva setuid"));
299        // escribible por otros CON el bit puesto = root para cualquiera
300        let mut s = doas();
301        s.mode = Some("4757".into());
302        assert!(s.validate().unwrap_err().to_string().contains("escribible"));
303        // ruta absoluta y `..`
304        let mut s = doas();
305        s.path = "/usr/bin/doas".into();
306        assert!(s.validate().is_err());
307        let mut s = doas();
308        s.path = "usr/../etc/shadow".into();
309        assert!(s.validate().is_err());
310        // sin motivo
311        let mut s = doas();
312        s.why = "  ".into();
313        assert!(s.validate().unwrap_err().to_string().contains("why"));
314    }
315
316    #[test]
317    fn dos_recetas_la_misma_ruta_es_error() {
318        let d = vec![("doas".to_string(), doas()), ("otra".to_string(), doas())];
319        assert!(validate_all(&d).unwrap_err().to_string().contains("DOS recetas"));
320        // la misma receta declarándolo una vez, no.
321        assert!(validate_all(&d[..1]).is_ok());
322    }
323
324    /// El test que justifica el módulo entero: aplicar el bit **no debe tocar el artefacto del
325    /// store**. Se monta un store de juguete, se «hidrata» por hardlink y se comprueba que después
326    /// del `aplicar` el original sigue `0555` y el del rootfs es `4755`.
327    #[test]
328    #[cfg(unix)]
329    fn no_contamina_el_artefacto_del_store() {
330        use std::os::unix::fs::PermissionsExt;
331        let t = tempfile::tempdir().unwrap();
332        let store = t.path().join("store/usr/bin");
333        let rootfs = t.path().join("rootfs/usr/bin");
334        std::fs::create_dir_all(&store).unwrap();
335        std::fs::create_dir_all(&rootfs).unwrap();
336        let orig = store.join("doas");
337        std::fs::write(&orig, b"binario").unwrap();
338        std::fs::set_permissions(&orig, std::fs::Permissions::from_mode(0o555)).unwrap();
339        std::fs::hard_link(&orig, rootfs.join("doas")).unwrap();
340
341        let decls = vec![("doas".to_string(), doas())];
342        let r = aplicar(&t.path().join("rootfs"), &decls).unwrap();
343        assert_eq!(r[0].2, Resultado::Puesto { rompio_hardlink: true });
344
345        let m_store = std::fs::metadata(&orig).unwrap().permissions().mode() & 0o7777;
346        let m_root = std::fs::metadata(rootfs.join("doas")).unwrap().permissions().mode() & 0o7777;
347        assert_eq!(m_store, 0o555, "el artefacto del store quedó con {m_store:o}");
348        assert_eq!(m_root, 0o4755);
349        // y el contenido es el mismo: se rompió el enlace, no se perdió el binario
350        assert_eq!(std::fs::read(rootfs.join("doas")).unwrap(), b"binario");
351        // idempotente: aplicar otra vez no hace nada
352        let r2 = aplicar(&t.path().join("rootfs"), &decls).unwrap();
353        assert_eq!(r2[0].2, Resultado::YaEstaba);
354    }
355
356    #[test]
357    #[cfg(unix)]
358    fn una_ruta_que_no_existe_no_pasa_por_buena() {
359        let t = tempfile::tempdir().unwrap();
360        std::fs::create_dir_all(t.path().join("usr/bin")).unwrap();
361        let r = aplicar(t.path(), &[("doas".into(), doas())]).unwrap();
362        assert_eq!(r[0].2, Resultado::NoExiste);
363    }
364
365    #[test]
366    #[cfg(unix)]
367    fn caza_el_setuid_que_nadie_declaro() {
368        use std::os::unix::fs::PermissionsExt;
369        let t = tempfile::tempdir().unwrap();
370        std::fs::create_dir_all(t.path().join("usr/bin")).unwrap();
371        for (n, m) in [("doas", 0o4755u32), ("colado", 0o4755), ("normal", 0o755), ("wall", 0o2755)] {
372            let p = t.path().join("usr/bin").join(n);
373            std::fs::write(&p, b"x").unwrap();
374            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(m)).unwrap();
375        }
376        let s = sin_declarar(t.path(), &[("doas".into(), doas())]).unwrap();
377        assert_eq!(
378            s,
379            vec![("usr/bin/colado".to_string(), 0o4755), ("usr/bin/wall".to_string(), 0o2755)]
380        );
381    }
382}