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>
This commit is contained in:
sergio
2026-05-21 17:47:05 +00:00
parent af3be482a9
commit 8a15b812f9
10 changed files with 572 additions and 2 deletions
+139
View File
@@ -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"));
}
}
@@ -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"
);
}
}
+79
View File
@@ -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);
}
}