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…
⎇ 0523ea066509b320d491ce444b9650bb1c7c9dfe
crates/takana-core/src/setuid.rs
History
Who touched what
raw
paso 2: el bit de privilegio se declara y se verifica — y `doas` queda solo (SDD 28 §5.11)
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}