diff --git a/Cargo.lock b/Cargo.lock index 200a35c0..8a36059c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,6 +549,7 @@ dependencies = [ "hammer-overlay", "hammer-upgrade", "hex", + "nix", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 5a041809..40205725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,10 @@ hex = "0.4" base64 = "0.22" ed25519-dalek = "2" getrandom = "0.2" -nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] } +# `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS — +# Wayland pasa descriptores (wl_shm, dmabuf) por el mismo socket, y sin relayarlos no dibuja +# nada. Las features de cargo son ADITIVAS: activarlas no cambia lo que compilan los demás. +nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio"] } tempfile = "3" clap = { version = "4", features = ["derive"] } tracing = "0.1" diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index 4616266d..509300fa 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -29,6 +29,7 @@ hammer-agent.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true +nix.workspace = true base64.workspace = true anyhow.workspace = true clap.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 83053b99..e63c427f 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand}; mod alpine_import; mod kernel_cmd; mod qorpa; +mod qorpa_wl; mod nix_import; const DEFAULT_STORE: &str = "/store"; diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index 740c8747..f044657e 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -608,6 +608,15 @@ struct Grants { /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan. #[serde(default)] network: bool, + /// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto. + /// + /// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan + /// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar. + #[serde(default)] + wayland_raw: bool, + /// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + wayland_globals: Vec, /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto. @@ -733,6 +742,7 @@ impl Default for Grants { Grants { sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(), network: false, nesting: false, root: true, seal_image: false, + wayland_raw: false, wayland_globals: Vec::new(), } } } @@ -843,7 +853,7 @@ fn recreate(root: &Path, id: &str) -> Result<()> { /// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario /// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara. -fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec, Vec) { +fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec, Vec) { let mut a: Vec = Vec::new(); let mut warn: Vec = Vec::new(); // Sin dep de libc: el dueño de /proc/self ES el uid efectivo. @@ -855,13 +865,19 @@ fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec, Vec) { for s in &g.sockets { let (host, dentro) = match s.as_str() { "wayland" => { - warn.push( - "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \ - socket es un borde de privilegio y todavía no hay proxy filtrante \ - (ADR §NO-resuelve 1)".into(), - ); let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); - (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0")) + match wl { + Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")), + None => { + warn.push( + "wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \ + compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \ + PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito." + .into(), + ); + (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0")) + } + } } "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")), "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")), @@ -1058,7 +1074,34 @@ fn run_instance( None => uid_dentro, }; let runtime_dir = format!("/run/user/{uid_corre}"); - let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir); + + // El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia. + // `_proxy` se mantiene en scope a propósito. + let mut proxy_sock: Option = None; + let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw { + let xdg = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| format!("/run/user/{}", uid_corre)); + let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); + let real = PathBuf::from(format!("{xdg}/{d}")); + if !real.exists() { + bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display()); + } + let permitidos: std::collections::HashSet = if inst.grants.wayland_globals.is_empty() { + crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect() + } else { + inst.grants.wayland_globals.iter().cloned().collect() + }; + let n = permitidos.len(); + let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos) + .context("no pude arrancar el proxy de Wayland")?; + eprintln!(" wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \ + para la instancia"); + proxy_sock = Some(p.socket.clone()); + Some(p) + } else { + None + }; + let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref()); // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo @@ -1934,12 +1977,12 @@ mod tests { #[test] fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { - let (a, w) = grants_to_args(&Grants::default(), "/run/user/0"); + let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None); assert!(a.contains(&"--unshare-net".to_string())); assert!(w.is_empty()); let g = Grants { network: true, ..Default::default() }; - let (a, _) = grants_to_args(&g, "/run/user/0"); + let (a, _) = grants_to_args(&g, "/run/user/0", None); assert!(!a.contains(&"--unshare-net".to_string())); // El segundo fallo clásico de la primera corrida, según el ADR. assert!(a.iter().any(|x| x == "/etc/resolv.conf")); @@ -1948,7 +1991,7 @@ mod tests { #[test] fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() { let g = Grants { sockets: vec!["wayland".into()], ..Default::default() }; - let (_, w) = grants_to_args(&g, "/run/user/0"); + let (_, w) = grants_to_args(&g, "/run/user/0", None); assert_eq!(w.len(), 1); assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); } diff --git a/crates/hammer-cli/src/qorpa_wl.rs b/crates/hammer-cli/src/qorpa_wl.rs new file mode 100644 index 00000000..0f012fca --- /dev/null +++ b/crates/hammer-cli/src/qorpa_wl.rs @@ -0,0 +1,458 @@ +//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md) +//! §NO-resuelve 1 y §Orden de trabajo 8. +//! +//! ── EL AGUJERO QUE CIERRA ─────────────────────────────────────────────────────────────────────── +//! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El +//! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie +//! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1` +//! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie, +//! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las +//! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy. +//! +//! ── POR QUÉ LISTA BLANCA Y NO NEGRA ───────────────────────────────────────────────────────────── +//! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene +//! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos +//! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una +//! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el +//! compositor, y sin un solo error visible. +//! +//! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ─────────────────────────────────────────── +//! El proxy relaya bytes salvo dos mensajes: +//! · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se +//! reenvía**. El cliente nunca se entera de que existe. +//! · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un +//! `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra. +//! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes +//! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada +//! sin asociarlos a su mensaje. + +use std::collections::HashSet; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags}; + +/// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames. +/// +/// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y +/// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`, +/// `idle_inhibit` (que no se apague la pantalla a media partida). +pub const GLOBALS_PERMITIDOS: &[&str] = &[ + "wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output", + "wl_data_device_manager", "wl_drm", + "xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1", + "wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1", + "wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1", + "wp_tearing_control_manager_v1", "wp_content_type_manager_v1", + "zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1", + "zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1", + "zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3", + "zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2", + "xdg_toplevel_icon_manager_v1", +]; + +const OBJ_DISPLAY: u32 = 1; +const OP_DISPLAY_GET_REGISTRY: u16 = 1; +const OP_DISPLAY_ERROR: u16 = 0; +const OP_REGISTRY_GLOBAL: u16 = 0; +const OP_REGISTRY_BIND: u16 = 0; +/// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad: +/// para este cliente ese global no existe. +const ERR_INVALID_OBJECT: u32 = 1; + +/// Estado compartido por las dos direcciones de una conexión. +#[derive(Default)] +struct Estado { + /// Los `wl_registry` que este cliente creó. Puede haber más de uno. + registries: HashSet, + /// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro. + ocultos: HashSet, +} + +pub struct Proxy { + /// El socket que se bindea DENTRO de la instancia, en vez del real. + pub socket: PathBuf, + _dir: PathBuf, +} + +/// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula. +/// +/// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura +/// la instancia (`hammer qorpa run` bloquea en bwrap). +pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet) -> io::Result { + std::fs::create_dir_all(dir)?; + let socket = dir.join("wayland-0"); + let _ = std::fs::remove_file(&socket); + let escucha = UnixListener::bind(&socket)?; + let real = socket_real.to_path_buf(); + let permitidos = Arc::new(permitidos); + + std::thread::spawn(move || { + for c in escucha.incoming() { + let Ok(cliente) = c else { continue }; + let real = real.clone(); + let permitidos = permitidos.clone(); + std::thread::spawn(move || { + if let Err(e) = atender(cliente, &real, permitidos) { + eprintln!("[qorpa-wl] conexión terminada: {e}"); + } + }); + } + }); + Ok(Proxy { socket, _dir: dir.to_path_buf() }) +} + +fn atender(cliente: UnixStream, real: &Path, permitidos: Arc>) -> io::Result<()> { + let servidor = UnixStream::connect(real)?; + let estado = Arc::new(Mutex::new(Estado::default())); + + let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone()); + let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2)); + let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos); + let _ = cliente.shutdown(std::net::Shutdown::Both); + let _ = servidor.shutdown(std::net::Shutdown::Both); + let _ = subida.join(); + r +} + +#[derive(PartialEq, Clone, Copy)] +enum Sentido { HaciaServidor, HaciaCliente } + +fn bombear( + de: &UnixStream, a: &UnixStream, sentido: Sentido, + estado: Arc>, permitidos: Arc>, +) -> io::Result<()> { + let mut pendiente: Vec = Vec::new(); + loop { + let (datos, fds) = leer(de)?; + if datos.is_empty() && fds.is_empty() { + return Ok(()); // el otro extremo cerró + } + pendiente.extend_from_slice(&datos); + + let mut salida: Vec = Vec::new(); + let mut consumido = 0usize; + while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) { + let msg = &pendiente[consumido..consumido + tam]; + if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? { + salida.extend_from_slice(msg); + } + consumido += tam; + } + pendiente.drain(..consumido); + + // Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que + // el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno. + if !salida.is_empty() || !fds.is_empty() { + escribir(a, &salida, &fds)?; + } + } +} + +/// Devuelve `true` si el mensaje se reenvía. +fn filtrar( + msg: &[u8], obj: u32, op: u16, sentido: Sentido, + estado: &Arc>, permitidos: &HashSet, otro: &UnixStream, +) -> io::Result { + let cuerpo = &msg[8..]; + match sentido { + Sentido::HaciaServidor => { + if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY { + if let Some(id) = leer_u32(cuerpo, 0) { + estado.lock().unwrap().registries.insert(id); + } + return Ok(true); + } + let es_registry = estado.lock().unwrap().registries.contains(&obj); + if es_registry && op == OP_REGISTRY_BIND { + if let Some(name) = leer_u32(cuerpo, 0) { + if estado.lock().unwrap().ocultos.contains(&name) { + // Bindear un global que nunca se anunció es un intento de saltarse el + // filtro, no un accidente: se corta la conexión diciendo por qué. + let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT, + "qorpa: ese global no existe para esta instancia"), &[]); + return Err(io::Error::other("bind a un global filtrado")); + } + } + } + Ok(true) + } + Sentido::HaciaCliente => { + let es_registry = estado.lock().unwrap().registries.contains(&obj); + if es_registry && op == OP_REGISTRY_GLOBAL { + let name = leer_u32(cuerpo, 0); + let interface = leer_str(cuerpo, 4); + if let (Some(name), Some(iface)) = (name, interface) { + if !permitidos.contains(&iface) { + estado.lock().unwrap().ocultos.insert(name); + return Ok(false); // el cliente nunca se entera + } + } + } + Ok(true) + } + } +} + +// ── el cable ──────────────────────────────────────────────────────────────────────────────────── + +/// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits +/// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero. +fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> { + if b.len() < 8 { + return None; + } + let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?); + let w = u32::from_ne_bytes(b[4..8].try_into().ok()?); + let tam = (w >> 16) as usize; + let op = (w & 0xffff) as u16; + // Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el + // proxy en un bucle, así que se corta. + if tam < 8 || b.len() < tam { + return None; + } + Some((obj, op, tam)) +} + +fn leer_u32(cuerpo: &[u8], off: usize) -> Option { + cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap())) +} + +/// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes. +fn leer_str(cuerpo: &[u8], off: usize) -> Option { + let len = leer_u32(cuerpo, off)? as usize; + if len == 0 { + return Some(String::new()); + } + let ini = off + 4; + let txt = cuerpo.get(ini..ini + len - 1)?; + Some(String::from_utf8_lossy(txt).into_owned()) +} + +fn escribir_str(out: &mut Vec, s: &str) { + let bytes = s.as_bytes(); + let len = bytes.len() + 1; + out.extend_from_slice(&(len as u32).to_ne_bytes()); + out.extend_from_slice(bytes); + out.push(0); + while out.len() % 4 != 0 { + out.push(0); + } +} + +fn error_display(obj: u32, code: u32, msg: &str) -> Vec { + let mut cuerpo = Vec::new(); + cuerpo.extend_from_slice(&obj.to_ne_bytes()); + cuerpo.extend_from_slice(&code.to_ne_bytes()); + escribir_str(&mut cuerpo, msg); + let tam = 8 + cuerpo.len(); + let mut out = Vec::with_capacity(tam); + out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes()); + out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes()); + out.extend_from_slice(&cuerpo); + out +} + +fn leer(s: &UnixStream) -> io::Result<(Vec, Vec)> { + let mut buf = [0u8; 8192]; + let mut espacio = nix::cmsg_space!([RawFd; 32]); + let mut iov = [io::IoSliceMut::new(&mut buf)]; + let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty()) + .map_err(io::Error::from)?; + let mut fds = Vec::new(); + for c in r.cmsgs().map_err(io::Error::from)? { + if let ControlMessageOwned::ScmRights(rs) = c { + for fd in rs { + fds.push(unsafe { OwnedFd::from_raw_fd(fd) }); + } + } + } + let n = r.bytes; + Ok((buf[..n].to_vec(), fds)) +} + +fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> { + let crudos: Vec = fds.iter().map(|f| f.as_raw_fd()).collect(); + let cmsgs: Vec = if crudos.is_empty() { + vec![] + } else { + vec![ControlMessage::ScmRights(&crudos)] + }; + // Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir, + // y los fds viajan sólo con el primer envío para no duplicarlos. + let mut off = 0; + loop { + let resto = &datos[off..]; + let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })]; + let usar = if off == 0 { &cmsgs[..] } else { &[][..] }; + let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None) + .map_err(io::Error::from)?; + off += n; + if off >= datos.len() { + return Ok(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec { + let tam = 8 + cuerpo.len(); + let mut v = Vec::with_capacity(tam); + v.extend_from_slice(&obj.to_ne_bytes()); + v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes()); + v.extend_from_slice(cuerpo); + v + } + + fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec { + let mut c = Vec::new(); + c.extend_from_slice(&name.to_ne_bytes()); + escribir_str(&mut c, iface); + c.extend_from_slice(&version.to_ne_bytes()); + msg(registry, OP_REGISTRY_GLOBAL, &c) + } + + /// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga. + fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> { + let l = UnixListener::bind(sock).unwrap(); + std::thread::spawn(move || { + let (mut c, _) = l.accept().unwrap(); + let mut buf = [0u8; 4096]; + let n = c.read(&mut buf).unwrap(); + // Lo único que se parsea: el id de registry que pidió el cliente. + let (obj, op, _) = cabecera(&buf[..n]).unwrap(); + assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY)); + let reg = leer_u32(&buf[8..n], 0).unwrap(); + let mut out = Vec::new(); + for (name, iface) in &anuncios { + out.extend_from_slice(&global(reg, *name, iface, 1)); + } + c.write_all(&out).unwrap(); + // Se queda viva para que el proxy no vea EOF antes de tiempo. + let _ = c.read(&mut buf); + }) + } + + fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec { + let mut v = Vec::new(); + let mut off = 0; + while let Some((obj, op, tam)) = cabecera(&datos[off..]) { + if obj == reg && op == OP_REGISTRY_GLOBAL { + if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) { + v.push(i); + } + } + off += tam; + } + v + } + + #[test] + fn el_cliente_no_se_entera_de_los_globals_peligrosos() { + let d = tempfile::tempdir().unwrap(); + let real = d.path().join("compositor.sock"); + let h = compositor_falso(&real, vec![ + (1, "wl_compositor".into()), + (2, "zwlr_screencopy_manager_v1".into()), // captura de pantalla + (3, "wl_seat".into()), + (4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas + (5, "zwlr_data_control_manager_v1".into()), // lee el portapapeles + (6, "xdg_wm_base".into()), + (7, "zwlr_layer_shell_v1".into()), // dibuja encima de todo + ]); + + let permitidos: HashSet = + GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); + let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap(); + + let mut c = UnixStream::connect(&p.socket).unwrap(); + let mut cuerpo = Vec::new(); + cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry + c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap(); + + c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut buf = [0u8; 8192]; + let n = c.read(&mut buf).unwrap(); + let vistas = interfaces_vistas(&buf[..n], 2); + + assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}"); + assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}"); + assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}"); + for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1", + "zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] { + assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}"); + } + assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}"); + // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del + // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él. + drop(c); + drop(h); + } + + #[test] + fn bindear_un_global_oculto_corta_la_conexion() { + let d = tempfile::tempdir().unwrap(); + let real = d.path().join("c.sock"); + let h = compositor_falso(&real, vec![ + (1, "wl_compositor".into()), + (99, "zwlr_screencopy_manager_v1".into()), + ]); + let permitidos: HashSet = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); + let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap(); + + let mut c = UnixStream::connect(&p.socket).unwrap(); + let mut cuerpo = Vec::new(); + cuerpo.extend_from_slice(&2u32.to_ne_bytes()); + c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap(); + c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut buf = [0u8; 8192]; + let _ = c.read(&mut buf).unwrap(); + + // Un cliente malicioso adivina el `name` que nunca se le anunció. + let mut b = Vec::new(); + b.extend_from_slice(&99u32.to_ne_bytes()); + escribir_str(&mut b, "zwlr_screencopy_manager_v1"); + b.extend_from_slice(&1u32.to_ne_bytes()); + b.extend_from_slice(&3u32.to_ne_bytes()); + c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap(); + + // Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind + // dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar. + let n = c.read(&mut buf).unwrap_or(0); + let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op)) + == Some((OBJ_DISPLAY, OP_DISPLAY_ERROR)); + let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 }; + assert!(hubo_error || cerro, "el bind al global oculto no fue cortado"); + // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del + // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él. + drop(c); + drop(h); + } + + #[test] + fn la_lista_es_BLANCA_no_negra() { + // Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una + // denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls. + let permitidos: HashSet = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); + assert!(!permitidos.contains("zwlr_screencopy_manager_v1")); + assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9")); + assert!(permitidos.contains("wl_compositor")); + } + + #[test] + fn el_parser_no_se_cuelga_con_un_stream_corrupto() { + // Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy. + let mut malo = Vec::new(); + malo.extend_from_slice(&1u32.to_ne_bytes()); + malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible + assert!(cabecera(&malo).is_none()); + assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto + } +} diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index b6e6be43..275e62a7 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -366,18 +366,45 @@ andar sin explicar por qué. bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton. +### D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls + +El compositor anuncia **todos** sus globals a quien se conecte. El proxy se mete en el medio, relaya +todo salvo dos mensajes y con eso alcanza: + +| mensaje | qué se hace | por qué | +|---|---|---| +| `wl_registry.global` (del compositor) | si el interface no está en la lista, **no se reenvía** | el cliente nunca se entera de que existe | +| `wl_registry.bind` (del cliente) | si el `name` es uno de los ocultos, `wl_display.error` y se corta | el `name` es **un número que se puede adivinar**: ocultar sin cortar el bind dejaría el filtro en decorativo | + +**Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón:** el +conjunto *peligroso* de Wayland **crece** —cada compositor inventa protocolos privilegiados— mientras +que el conjunto *necesario para dibujar una ventana* es corto y estable. Una denylist estaría +desactualizada el día que alguien actualice el compositor, **y sin un solo error visible**. La lista +incluye a propósito lo que los juegos piden y suele olvidarse: `pointer_constraints`, +`relative_pointer`, `tearing_control`, `idle_inhibit`. + +**Lo que hace esto viable en un fichero:** ningún mensaje que se descarta lleva descriptores, así que +los fds (que Wayland pasa por `SCM_RIGHTS` para `wl_shm` y dmabuf, y sin los cuales no se dibuja +nada) se relayan en orden de llegada **sin tener que asociarlos a su mensaje**. + +**Probado sin compositor**, con uno FALSO que anuncia siete globals —tres permitidos y cuatro +peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear +por número el `name` que nunca se anunció corta la conexión. Es la clase de prueba que no necesita +GPU y aun así responde la pregunta entera. Un quinto test cubre el `size` menor que la cabecera, que +haría avanzar 0 bytes y colgar el proxy en un bucle. + --- ## Lo que este ADR admite que NO resuelve Se escriben acá para que no se descubran en producción. -1. **El socket de Wayland es un borde de privilegio, no un caño.** Pasarlo crudo le da a la imagen - ajena todos los protocolos privilegiados que exponga el compositor: `screencopy`, - `virtual-keyboard`, `input-method`. Flatpak no pasa el socket: pasa un **proxy que filtra - globals**. No lo tenemos. Mientras no exista, **una instancia con socket de Wayland puede - capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario. - Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR. +1. **El socket de Wayland — ~~el agujero~~ CERRADO 2026-09-03.** Se escribió el proxy que faltaba + (D11): la instancia ya no ve el registro entero del compositor sino una **lista blanca**. Lo que + queda del párrafo original es la razón por la que se hizo: pasar el socket crudo **no es abrir un + caño, es un borde de privilegio** — `screencopy` captura la pantalla, `virtual_keyboard` + sintetiza teclas, `data_control` lee el portapapeles sin que nadie copie y `layer_shell` dibuja + encima de todo. Sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos. 2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt` @@ -488,7 +515,7 @@ Se escriben acá para que no se descubran en producción. tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`, porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar. -8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista. +8. ✅ **HECHO 2026-09-03.** Proxy filtrante de Wayland. Ver D11. ## Nombre — ADOPTADO 2026-09-03