diff --git a/Cargo.lock b/Cargo.lock index 72d26f9..eb14388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1522,6 +1522,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.11.0", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -1806,6 +1826,16 @@ dependencies = [ "ulid", ] +[[package]] +name = "brahman-auth" +version = "0.1.0" +dependencies = [ + "nix 0.29.0", + "pam", + "rpassword", + "thiserror 2.0.18", +] + [[package]] name = "brahman-broker" version = "0.1.0" @@ -5086,7 +5116,7 @@ dependencies = [ "ashpd 0.11.1", "async-task", "backtrace", - "bindgen", + "bindgen 0.71.1", "blade-graphics", "blade-macros", "blade-util", @@ -5238,7 +5268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" dependencies = [ "anyhow", - "bindgen", + "bindgen 0.71.1", "core-foundation 0.10.0", "core-video", "ctor", @@ -6557,6 +6587,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leak" version = "0.1.2" @@ -9397,6 +9433,40 @@ dependencies = [ "windows 0.59.0", ] +[[package]] +name = "pam" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" +dependencies = [ + "libc", + "memchr", + "pam-macros", + "pam-sys", + "users", +] + +[[package]] +name = "pam-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" +dependencies = [ + "bindgen 0.69.5", + "libc", +] + [[package]] name = "parking" version = "2.2.1" @@ -14139,6 +14209,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "users" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" +dependencies = [ + "libc", + "log", +] + [[package]] name = "usvg" version = "0.45.1" diff --git a/Cargo.toml b/Cargo.toml index 01d39d6..5ec9165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/protocol/brahman-dht", "crates/protocol/brahman-card-discovery", "crates/protocol/brahman-ssh-multiplex", + "crates/protocol/brahman-auth", "crates/protocol/arje-card", # ============================================================ @@ -374,6 +375,9 @@ notify = "6.1" clap = { version = "4", features = ["derive"] } rpassword = "7" +# === PAM (brahman-auth) === +pam = "0.8" + # === D-Bus (arje compat) === zbus = { version = "4", default-features = false, features = ["tokio"] } diff --git a/crates/protocol/brahman-auth/Cargo.toml b/crates/protocol/brahman-auth/Cargo.toml new file mode 100644 index 0000000..f4f393a --- /dev/null +++ b/crates/protocol/brahman-auth/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "brahman-auth" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "brahman-auth — autenticación del escritorio: contrato Authenticator agnóstico + backend PAM + mock. Lo consume el greeter de carmen (mirada)." + +[dependencies] +nix = { workspace = true } +thiserror = { workspace = true } +pam = { workspace = true } + +[dev-dependencies] +rpassword = { workspace = true } diff --git a/crates/protocol/brahman-auth/README.md b/crates/protocol/brahman-auth/README.md new file mode 100644 index 0000000..660c0cc --- /dev/null +++ b/crates/protocol/brahman-auth/README.md @@ -0,0 +1,52 @@ +# brahman-auth + +Autenticación del escritorio. Contrato `Authenticator` agnóstico del +backend, con dos implementaciones. + +## Para qué + +El greeter de carmen (mirada) necesita verificar la contraseña del +usuario y, en éxito, saber su `uid/gid/home/shell` para arrancar la +sesión. Eso es exactamente lo que entrega `Authenticator::authenticate`: + +```rust +use brahman_auth::{Authenticator, PamAuthenticator}; + +let auth = PamAuthenticator::carmen(); +match auth.authenticate("sergio", &password) { + Ok(info) => arrancar_sesion(info), // info: UserInfo + Err(e) => mostrar_error_en_greeter(e), +} +``` + +## Backends + +- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/`), + el mismo subsistema de `login` y `sudo`. Hereda lo que el + administrador configure ahí (2FA, FIDO2, `pam_faillock`…) sin que el + crate lo sepa. +- **`MockAuthenticator`** — credenciales fijas en memoria. Para tests y + para iterar el greeter en cajas sin PAM configurado. + +`AuthError` es deliberadamente grueso: el greeter sólo distingue +"reintentá" (`BadCredentials`) de "cuenta vetada" (`AccountUnavailable`), +y nunca puede saber si un usuario existe. + +## Servicio PAM + +`data/carmen` es el archivo de servicio. Instalarlo: + +```sh +install -Dm644 data/carmen /etc/pam.d/carmen +``` + +Ajustar el `include` a la pila de login de la distribución (ver los +comentarios del archivo). + +## Probar contra PAM en una máquina real + +```sh +cargo run -p brahman-auth --example auth-probe -- "$USER" login +``` + +Pide la contraseña sin eco e informa el `UserInfo` resuelto. diff --git a/crates/protocol/brahman-auth/data/carmen b/crates/protocol/brahman-auth/data/carmen new file mode 100644 index 0000000..b50c380 --- /dev/null +++ b/crates/protocol/brahman-auth/data/carmen @@ -0,0 +1,15 @@ +#%PAM-1.0 +# +# Servicio PAM del greeter de carmen (mirada). Instalar como +# /etc/pam.d/carmen. +# +# El `include` apunta a la pila de login de la distribución; ajustar +# según corresponda: +# Arch system-login +# Debian/Ubuntu common-auth / common-account / ... (una por línea) +# Fedora/RHEL system-auth + postlogin +# +auth include system-login +account include system-login +password include system-login +session include system-login diff --git a/crates/protocol/brahman-auth/examples/auth-probe.rs b/crates/protocol/brahman-auth/examples/auth-probe.rs new file mode 100644 index 0000000..920e39b --- /dev/null +++ b/crates/protocol/brahman-auth/examples/auth-probe.rs @@ -0,0 +1,41 @@ +//! Prueba interactiva de `brahman-auth` contra PAM. Sirve para verificar +//! la configuración de `/etc/pam.d/` en una máquina real. +//! +//! `cargo run -p brahman-auth --example auth-probe -- [usuario] [servicio]` +//! +//! Pide la contraseña sin eco. El servicio por defecto es `carmen`; si +//! `/etc/pam.d/carmen` aún no está instalado, probar con `login`. + +use brahman_auth::{Authenticator, PamAuthenticator}; + +fn main() { + let mut args = std::env::args().skip(1); + let user = args + .next() + .or_else(|| std::env::var("USER").ok()) + .unwrap_or_else(|| "root".into()); + let service = args.next().unwrap_or_else(|| "carmen".into()); + + let password = match rpassword::prompt_password(format!("Contraseña de {user}: ")) { + Ok(p) => p, + Err(e) => { + eprintln!("no se pudo leer la contraseña: {e}"); + std::process::exit(2); + } + }; + + let auth = PamAuthenticator::new(&service); + println!("autenticando «{user}» contra el servicio PAM «{service}»…"); + match auth.authenticate(&user, &password) { + Ok(info) => { + println!("✓ autenticado"); + println!(" uid={} gid={}", info.uid, info.gid); + println!(" home={}", info.home.display()); + println!(" shell={}", info.shell.display()); + } + Err(e) => { + eprintln!("✗ {e}"); + std::process::exit(1); + } + } +} diff --git a/crates/protocol/brahman-auth/src/lib.rs b/crates/protocol/brahman-auth/src/lib.rs new file mode 100644 index 0000000..8eb0d1e --- /dev/null +++ b/crates/protocol/brahman-auth/src/lib.rs @@ -0,0 +1,139 @@ +//! `brahman-auth` — autenticación del escritorio. +//! +//! Contrato [`Authenticator`] agnóstico del backend, con dos +//! implementaciones: +//! +//! - [`PamAuthenticator`] — el camino real: verifica contra PAM +//! (`/etc/pam.d/`), el mismo subsistema que usan `login`, +//! `sudo` y los gestores de login clásicos. Hereda lo que el +//! administrador configure ahí (2FA, llaves FIDO2, `pam_faillock`…) +//! sin que `brahman-auth` tenga que saberlo. +//! - [`MockAuthenticator`] — credenciales fijas en memoria, para tests +//! y para iterar el greeter en cajas sin PAM configurado. +//! +//! Lo consume el greeter de carmen (mirada): el usuario teclea su +//! contraseña, el greeter llama a [`Authenticator::authenticate`], y en +//! éxito recibe un [`UserInfo`] con uid/gid/home/shell — lo que el +//! compositor necesita para arrancar la sesión. + +mod pam_backend; +mod user; + +pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE}; +pub use user::{resolve_user, UserInfo}; + +use std::collections::HashMap; + +/// Por qué falló una autenticación. Variantes deliberadamente gruesas: +/// el greeter sólo necesita saber si conviene reintentar (problema de +/// credenciales) o si la cuenta está vetada — y nunca debe poder +/// distinguir "usuario inexistente" de "contraseña errada". +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AuthError { + /// Usuario o contraseña incorrectos. El greeter deja reintentar sin + /// revelar cuál de los dos falló. + #[error("usuario o contraseña incorrectos")] + BadCredentials, + + /// Las credenciales son válidas pero la cuenta está deshabilitada, + /// expirada o requiere una acción (cambio de contraseña). + #[error("la cuenta no está disponible: {0}")] + AccountUnavailable(String), + + /// Fallo del subsistema PAM no atribuible a las credenciales + /// (servicio mal configurado, módulo roto, etc.). + #[error("fallo de PAM: {0}")] + Pam(String), + + /// No se pudo resolver la identidad del usuario en el sistema tras + /// una autenticación válida (caso raro: `/etc/passwd` inconsistente). + #[error("no se pudo resolver el usuario «{0}» en el sistema")] + UnresolvedUser(String), +} + +/// Verifica credenciales y, en éxito, entrega la identidad del sistema. +/// +/// `&self`: cada llamada es un intento de login independiente. Las +/// implementaciones crean su propio estado por intento — PAM exige un +/// handle nuevo por transacción, reusarlo entre intentos es un bug. +pub trait Authenticator { + fn authenticate(&self, username: &str, secret: &str) -> Result; +} + +/// Autenticador de credenciales fijas en memoria. No toca PAM: sirve +/// para tests y para iterar el greeter en cajas headless sin +/// `/etc/pam.d` configurado. +#[derive(Debug, Default, Clone)] +pub struct MockAuthenticator { + creds: HashMap, +} + +impl MockAuthenticator { + /// Crea un autenticador sin usuarios: todo intento falla con + /// [`AuthError::BadCredentials`] hasta registrar alguno. + pub fn new() -> Self { + Self::default() + } + + /// Registra un par usuario/secreto aceptado. Encadenable. + pub fn with_user(mut self, username: &str, secret: &str) -> Self { + self.creds.insert(username.to_string(), secret.to_string()); + self + } +} + +impl Authenticator for MockAuthenticator { + fn authenticate(&self, username: &str, secret: &str) -> Result { + // Mismo error para usuario inexistente y para contraseña errada: + // no filtra la existencia de cuentas. + match self.creds.get(username) { + Some(expected) if expected == secret => { + // Si el usuario existe en el SO, info real; sino, + // sintética (suficiente para tests y dev headless). + Ok(resolve_user(username).unwrap_or_else(|_| UserInfo::synthetic(username))) + } + _ => Err(AuthError::BadCredentials), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_accepts_registered_user() { + let auth = MockAuthenticator::new().with_user("sergio", "clave"); + let info = auth.authenticate("sergio", "clave").expect("debe pasar"); + assert_eq!(info.name, "sergio"); + } + + #[test] + fn mock_rejects_wrong_password() { + let auth = MockAuthenticator::new().with_user("sergio", "clave"); + assert_eq!( + auth.authenticate("sergio", "mala"), + Err(AuthError::BadCredentials) + ); + } + + #[test] + fn mock_unknown_user_indistinguishable_from_wrong_password() { + let auth = MockAuthenticator::new().with_user("sergio", "clave"); + assert_eq!( + auth.authenticate("nadie", "x"), + Err(AuthError::BadCredentials) + ); + } + + #[test] + fn empty_mock_rejects_everything() { + assert!(MockAuthenticator::new().authenticate("root", "").is_err()); + } + + #[test] + fn auth_error_is_displayable() { + assert!(!AuthError::BadCredentials.to_string().is_empty()); + assert!(AuthError::Pam("x".into()).to_string().contains("PAM")); + } +} diff --git a/crates/protocol/brahman-auth/src/pam_backend.rs b/crates/protocol/brahman-auth/src/pam_backend.rs new file mode 100644 index 0000000..05e03ec --- /dev/null +++ b/crates/protocol/brahman-auth/src/pam_backend.rs @@ -0,0 +1,120 @@ +//! Backend PAM del contrato [`Authenticator`](crate::Authenticator). +//! +//! El módulo se llama `pam_backend` (no `pam`) para no chocar con el +//! crate externo `pam`, del que depende. + +use pam::{Client, PamError, PamReturnCode}; + +use crate::{resolve_user, AuthError, Authenticator, UserInfo}; + +/// Servicio PAM por defecto del escritorio carmen. Resuelve a +/// `/etc/pam.d/carmen` — ver el archivo `data/carmen` de este crate. +pub const DEFAULT_SERVICE: &str = "carmen"; + +/// Autentica contra PAM: el mismo subsistema de `login`/`sudo`. Honra +/// `/etc/pam.d/` — módulos, 2FA, llaves FIDO2, `pam_faillock`, +/// lo que el administrador configure ahí, sin que `brahman-auth` lo sepa. +#[derive(Debug, Clone)] +pub struct PamAuthenticator { + service: String, +} + +impl PamAuthenticator { + /// Autenticador para un servicio PAM concreto (`/etc/pam.d/`). + pub fn new(service: impl Into) -> Self { + Self { + service: service.into(), + } + } + + /// Autenticador para el servicio por defecto del escritorio, + /// [`DEFAULT_SERVICE`]. + pub fn carmen() -> Self { + Self::new(DEFAULT_SERVICE) + } + + /// Nombre del servicio PAM que usa este autenticador. + pub fn service(&self) -> &str { + &self.service + } +} + +impl Default for PamAuthenticator { + fn default() -> Self { + Self::carmen() + } +} + +impl Authenticator for PamAuthenticator { + fn authenticate(&self, username: &str, secret: &str) -> Result { + // Un handle PAM nuevo por intento: PAM es stateful por + // transacción y reusar el handle entre intentos es un bug. El + // `Client` cierra la transacción (`pam_end`) en su `Drop`. + let mut client = Client::with_password(&self.service) + .map_err(|e| AuthError::Pam(format!("pam_start({}): {e}", self.service)))?; + client.conversation_mut().set_credentials(username, secret); + + // `authenticate()` del crate hace pam_authenticate + pam_acct_mgmt: + // cubre credenciales Y estado de la cuenta en un solo paso. + client.authenticate().map_err(map_pam_error)?; + + // Credenciales válidas: resolvemos la identidad del sistema. + resolve_user(username) + } +} + +/// Traduce un error de PAM a la taxonomía gruesa de [`AuthError`]. +fn map_pam_error(err: PamError) -> AuthError { + match err.0 { + // Credenciales: el greeter debe dejar reintentar. + PamReturnCode::Auth_Err + | PamReturnCode::User_Unknown + | PamReturnCode::Cred_Insufficient + | PamReturnCode::MaxTries => AuthError::BadCredentials, + + // Cuenta válida pero vetada o que requiere una acción. + PamReturnCode::Acct_Expired => AuthError::AccountUnavailable("la cuenta expiró".into()), + PamReturnCode::Cred_Expired => { + AuthError::AccountUnavailable("las credenciales expiraron".into()) + } + PamReturnCode::AuthTok_Expired => { + AuthError::AccountUnavailable("la contraseña expiró".into()) + } + PamReturnCode::New_Authtok_Reqd => { + AuthError::AccountUnavailable("requiere cambiar la contraseña".into()) + } + PamReturnCode::Perm_Denied => { + AuthError::AccountUnavailable("acceso denegado por política".into()) + } + + // Todo lo demás: fallo de infraestructura PAM. + other => AuthError::Pam(format!("{other:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn carmen_uses_default_service() { + assert_eq!(PamAuthenticator::carmen().service(), DEFAULT_SERVICE); + assert_eq!(PamAuthenticator::default().service(), "carmen"); + } + + #[test] + fn custom_service_name() { + assert_eq!(PamAuthenticator::new("login").service(), "login"); + } + + #[test] + fn unknown_service_fails_gracefully() { + // Sin `/etc/pam.d/` PAM cae a `other` (deny). Debe + // devolver un `AuthError`, nunca paniquear. + let auth = PamAuthenticator::new("brahman-auth-servicio-inexistente-xyz"); + assert!( + auth.authenticate("root", "contraseña-cualquiera").is_err(), + "un servicio inexistente debe fallar limpio" + ); + } +} diff --git a/crates/protocol/brahman-auth/src/user.rs b/crates/protocol/brahman-auth/src/user.rs new file mode 100644 index 0000000..55f5532 --- /dev/null +++ b/crates/protocol/brahman-auth/src/user.rs @@ -0,0 +1,79 @@ +//! Resolución de la identidad de un usuario del sistema. + +use std::path::PathBuf; + +use crate::AuthError; + +/// Identidad de un usuario en el sistema: lo que el compositor necesita +/// para arrancar una sesión — fijar uid/gid, `cd` al home, ejecutar el +/// shell o la sesión de escritorio. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserInfo { + /// Nombre de login. + pub name: String, + /// User ID. + pub uid: u32, + /// Group ID primario. + pub gid: u32, + /// Directorio personal. + pub home: PathBuf, + /// Shell de login. + pub shell: PathBuf, +} + +impl UserInfo { + /// Identidad sintética para tests y para cajas donde el usuario no + /// está en `/etc/passwd`. **No** representa a un usuario real del SO + /// — no usar para fijar privilegios de un proceso real. + pub fn synthetic(name: &str) -> Self { + Self { + name: name.to_string(), + uid: 1000, + gid: 1000, + home: PathBuf::from(format!("/home/{name}")), + shell: PathBuf::from("/bin/sh"), + } + } +} + +/// Resuelve un usuario por nombre vía `getpwnam`. `Err` si no existe o +/// si la consulta a `/etc/passwd` (o NSS) falla. +pub fn resolve_user(name: &str) -> Result { + match nix::unistd::User::from_name(name) { + Ok(Some(u)) => Ok(UserInfo { + name: u.name, + uid: u.uid.as_raw(), + gid: u.gid.as_raw(), + home: u.dir, + shell: u.shell, + }), + Ok(None) => Err(AuthError::UnresolvedUser(name.to_string())), + Err(e) => Err(AuthError::Pam(format!("getpwnam({name}): {e}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_root() { + // root (uid 0) existe en todo sistema Unix. + let info = resolve_user("root").expect("root debe existir"); + assert_eq!(info.uid, 0); + assert_eq!(info.name, "root"); + } + + #[test] + fn unknown_user_errs() { + let r = resolve_user("usuario-que-no-existe-xyzzy"); + assert!(matches!(r, Err(AuthError::UnresolvedUser(_)))); + } + + #[test] + fn synthetic_has_home_under_slash_home() { + let info = UserInfo::synthetic("prueba"); + assert_eq!(info.home, PathBuf::from("/home/prueba")); + assert_eq!(info.uid, 1000); + } +} diff --git a/docs/changelog/protocol.md b/docs/changelog/protocol.md index 32104c2..aa2e945 100644 --- a/docs/changelog/protocol.md +++ b/docs/changelog/protocol.md @@ -2,6 +2,29 @@ Contratos canónicos + routing entre módulos. Antes: `core/brahman-*` + `shared/brahman-*`. +### feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock + +Crate nuevo `crates/protocol/brahman-auth`: la base del DM/greeter de +carmen (mirada). Contrato `Authenticator` agnóstico del backend: +`authenticate(usuario, secreto) -> UserInfo`, donde `UserInfo` lleva +`uid/gid/home/shell` — lo que el compositor necesita para arrancar la +sesión. + +- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/`, + por defecto `carmen`), el mismo subsistema de `login`/`sudo`. Un handle + PAM nuevo por intento; `authenticate()` cubre credenciales + estado de + la cuenta. `map_pam_error` traduce los `PamReturnCode` a la taxonomía + gruesa de `AuthError`. +- **`MockAuthenticator`** — credenciales fijas en memoria, para tests y + para iterar el greeter en cajas sin PAM. +- `AuthError` deliberadamente grueso: `BadCredentials` (reintentar) vs + `AccountUnavailable` (cuenta vetada); usuario inexistente y contraseña + errada dan el **mismo** error (no filtra existencia de cuentas). +- `resolve_user` vía `getpwnam` (nix). `UserInfo::synthetic` para dev. +- `data/carmen` — archivo de servicio PAM. Ejemplo `auth-probe` para + verificar PAM en una máquina real. +- 11 tests; el camino PAM real se ejercita (falla limpio sin servicio). + ### feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o future-me retomando el repo) puede levantar el escenario completo