gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definitions

feat(auth): brahman-auth — autenticación del escritorio (PAM + mock)

Base del DM/greeter de carmen. Contrato Authenticator agnóstico:
authenticate(usuario, secreto) -> UserInfo (uid/gid/home/shell).
PamAuthenticator verifica contra PAM (/etc/pam.d/carmen); MockAuthenticator
con credenciales en memoria para tests. AuthError grueso: BadCredentials
vs AccountUnavailable, sin filtrar existencia de cuentas. resolve_user
vía getpwnam. data/carmen como servicio PAM; ejemplo auth-probe.

11 tests; el camino PAM real se ejercita.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

8a15b812f9507defc8726286bd4a541f1f789243 · by sergio · (5 months ago) · parent af3be482a · browse the tree at this commit

Semantic delta · 34

crates/protocol/brahman-auth/examples/auth-probe.rs

crates/protocol/brahman-auth/src/lib.rs

crates/protocol/brahman-auth/src/pam_backend.rs

crates/protocol/brahman-auth/src/user.rs

10 files · +572 −2

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/<servicio>`),
+  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/<servicio>` 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/<servicio>`), 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<UserInfo, AuthError>;
+}
+
+/// 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<String, String>,
+}
+
+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<UserInfo, AuthError> {
+        // 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/<service>` — 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/<service>`).
+    pub fn new(service: impl Into<String>) -> 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<UserInfo, AuthError> {
+        // 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/<servicio>` 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<UserInfo, AuthError> {
+    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/<servicio>`,
+  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