La mayoria de las plataformas PaaS externalizan la autenticacion a un servicio de terceros. Auth0, Clerk, Supabase Auth -- no faltan opciones. Pero sh0.dev se distribuye como un unico binario que ejecutas en tu propio servidor sin dependencias externas. No hay "llamar a un proveedor de auth hospedado" cuando tu eres toda la plataforma. Tuvimos que construir cada capa nosotros mismos: hashing de contrasenas, emision de tokens, autenticacion de dos factores, gestion de claves API y un sistema de cifrado maestro para proteger secretos en reposo.
Este articulo recorre las cinco capas de autenticacion que construimos en la Fase 9, las decisiones de diseno detras de cada una y el codigo que las une.
El crate sh0-auth
La autenticacion vive en su propio crate -- sh0-auth -- separado de los handlers de la API, la capa de base de datos y el binario principal. Esta separacion es deliberada. La logica de auth cambia raramente. Necesita su propia suite de pruebas. Y aislar el codigo criptografico en un modulo enfocado hace que la auditoria sea manejable.
El crate expone cinco modulos: password, jwt, api_key, crypto y totp. Cada uno posee una unica responsabilidad. La capa API (sh0-api) llama a sh0-auth a traves de limites de funcion limpios; nunca toca primitivas criptograficas crudas directamente.
Capa 1: Hashing de contrasenas Argon2id
Elegimos Argon2id -- el ganador de la Competicion de Hashing de Contrasenas de 2015 -- para el almacenamiento de contrasenas. Combina la resistencia de Argon2i a ataques de canal lateral con la resistencia de Argon2d al cracking por GPU. La implementacion usa el crate argon2 con salida en formato PHC string, que codifica el algoritmo, version, coste de memoria, coste de tiempo, paralelismo, salt y hash en una unica cadena auto-descriptiva.
rustuse argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, AuthError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); // Argon2id v19, 19MB memoria, 2 iteraciones
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|_| AuthError::HashFailed)?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidHash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}El formato PHC importa porque hace que el hash sea auto-actualizable. Si despues aumentamos el coste de memoria de 19 MiB a 64 MiB, los hashes existentes aun se verifican correctamente -- los parametros estan embebidos en la cadena. Las nuevas contrasenas obtienen los parametros mas fuertes. Sin necesidad de migracion.
Un punto sutil: durante la auditoria de seguridad (mas sobre eso en un articulo posterior), descubrimos una vulnerabilidad de enumeracion de usuarios por timing. Cuando un intento de login apuntaba a un usuario inexistente, el servidor respondia inmediatamente -- sin calculo de hash. Un atacante podia distinguir "usuario no encontrado" de "contrasena incorrecta" midiendo el tiempo de respuesta. La correccion: siempre ejecutar una verificacion Argon2id ficticia incluso cuando el usuario no existe.
Capa 2: Tokens JWT (HS256, 7 dias de expiracion)
Una vez que un usuario se autentica con su contrasena, sh0 emite un JSON Web Token. Usamos HS256 (HMAC-SHA256) con firma simetrica via el crate jsonwebtoken. Los algoritmos asimetricos como RS256 tienen sentido cuando multiples servicios necesitan verificar tokens independientemente. sh0 es un unico binario -- no hay service mesh, ni limite de microservicios. HS256 es mas simple, mas rapido y perfectamente apropiado.
rustuse jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // ID de usuario
pub email: String,
pub role: String,
pub exp: usize, // expiracion (timestamp Unix)
pub iat: usize, // emitido en
pub jti: String, // ID unico del token (UUID v4)
}
pub fn create_token(user_id: &str, email: &str, role: &str, secret: &[u8]) -> Result<String, AuthError> {
let now = chrono::Utc::now().timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
email: email.to_string(),
role: role.to_string(),
exp: now + 7 * 24 * 60 * 60, // 7 dias
iat: now,
jti: Uuid::new_v4().to_string(),
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret))
.map_err(|_| AuthError::TokenCreationFailed)
}Cada token recibe un claim jti (JWT ID) UUID v4. Esto sirve dos propositos: hace cada token unico incluso si se emite en el mismo segundo para el mismo usuario, y proporciona un identificador para futura revocacion de tokens si anadimos una lista de bloqueo.
El secreto JWT en si se auto-genera en la primera ejecucion -- 64 bytes de un RNG criptografico, escritos a un archivo en la ruta especificada por --jwt-secret-path. En arranques posteriores, el binario carga el secreto existente. Esto significa que los tokens sobreviven a reinicios del servidor pero son unicos para cada instalacion.
Capa 3: Claves API con busqueda por prefijo
Los tokens estan bien para sesiones de navegador, pero los pipelines CI/CD y las herramientas CLI necesitan algo persistente. Para eso son las claves API. Nuestro formato de clave: prefijo sh0_ seguido de 32 caracteres alfanumericos criptograficamente aleatorios.
El diseno se inspira en el enfoque de Stripe: nunca almacenamos la clave completa. En su lugar, almacenamos un hash SHA-256 de la clave y una columna key_prefix separada que contiene los primeros 8 caracteres despues de sh0_. Cuando llega una peticion con una clave API, extraemos el prefijo, buscamos claves candidatas por prefijo (una consulta indexada rapida), luego verificamos la clave completa contra el hash almacenado usando comparacion en tiempo constante.
rustuse ring::rand::{SecureRandom, SystemRandom};
use ring::digest::{digest, SHA256};
use subtle::ConstantTimeEq;
const KEY_PREFIX: &str = "sh0_";
const KEY_LENGTH: usize = 32;
pub fn generate_api_key() -> (String, String, String) {
let rng = SystemRandom::new();
let mut key_bytes = [0u8; KEY_LENGTH];
rng.fill(&mut key_bytes).expect("RNG failure");
let key_chars: String = key_bytes.iter()
.map(|b| {
let charset = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
charset[(*b as usize) % charset.len()] as char
})
.collect();
let full_key = format!("{}{}", KEY_PREFIX, key_chars);
let prefix = key_chars[..8].to_string();
let hash = hex::encode(digest(&SHA256, full_key.as_bytes()).as_ref());
(full_key, prefix, hash) // full_key se muestra una vez; prefix + hash se almacenan en BD
}La comparacion en tiempo constante merece enfasis. Nuestra implementacion inicial usaba == para comparar hashes -- una vulnerabilidad clasica de ataque de timing. Si el primer byte no coincide, == retorna inmediatamente; si los primeros 31 bytes coinciden, toma mediblemente mas tiempo. Un atacante podria reconstruir el hash byte a byte. El trait ConstantTimeEq del crate subtle elimina esto comparando siempre cada byte sin importar la posicion de coincidencia.
La columna key_prefix tiene un indice de base de datos. Cuando llega una peticion con sh0_aBcDeFgH..., consultamos WHERE key_prefix = 'aBcDeFgH' -- tipicamente retornando exactamente una fila -- luego verificamos el hash SHA-256 completo. Esto evita escanear cada clave en la tabla en cada peticion autenticada.
Capa 4: Cifrado maestro AES-256-GCM
Un PaaS almacena secretos: contrasenas de base de datos, claves API de servicios de terceros, variables de entorno marcadas como sensibles. Estos no pueden vivir en texto plano en la base de datos. sh0 usa AES-256-GCM (Galois/Counter Mode) para cifrado de sobre, con claves derivadas de una frase de paso maestra via PBKDF2.
rustuse ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use ring::pbkdf2;
use ring::rand::{SecureRandom, SystemRandom};
const PBKDF2_ITERATIONS: u32 = 100_000;
pub fn derive_key(passphrase: &str, salt: &[u8]) -> LessSafeKey {
let mut key_bytes = [0u8; 32];
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA256,
std::num::NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(),
salt,
passphrase.as_bytes(),
&mut key_bytes,
);
let unbound = UnboundKey::new(&AES_256_GCM, &key_bytes).unwrap();
LessSafeKey::new(unbound)
}La arquitectura funciona asi: en la primera ejecucion, si la variable de entorno SH0_MASTER_PASSPHRASE esta configurada, sh0 deriva una clave maestra usando PBKDF2 con 100.000 iteraciones y un salt aleatorio. El salt (pero no la clave) se almacena junto con los datos cifrados. Cada operacion de cifrado genera un nonce fresco de 96 bits. El nonce y el texto cifrado se concatenan para almacenamiento; el descifrado los separa.
El modo GCM proporciona tanto confidencialidad como autenticidad -- si un atacante modifica el texto cifrado, el descifrado falla en lugar de producir texto plano corrupto. Esto importa cuando los valores cifrados son credenciales de base de datos que se inyectaran en contenedores en ejecucion.
La clave maestra se carga en AppState al arrancar y esta disponible para cualquier handler que necesite cifrar o descifrar. Nunca sale de la memoria del servidor, nunca se registra en logs, y nunca se incluye en respuestas de la API.
Capa 5: Autenticacion de dos factores TOTP
La capa final son las contrasenas de un solo uso basadas en tiempo (RFC 6238). Implementamos esto con el crate totp-rs, generando codigos de 6 digitos con una ventana de 30 segundos y tolerancia de +/-1 paso (lo que significa que el codigo de la ventana de 30 segundos anterior y siguiente tambien se aceptan, contemplando la desviacion de reloj).
El flujo de configuracion TOTP tiene tres etapas:
- Configuracion: el servidor genera un secreto base32 aleatorio y lo devuelve junto con una URI
otpauth://(para escaneo de codigo QR) y unsetup_nonce. - Confirmacion: el usuario envia el
setup_noncemas un codigo TOTP valido de su app autenticadora. Esto demuestra que registraron exitosamente el secreto. El servidor almacena el secreto y genera 10 codigos de respaldo. - Login con 2FA: despues de que la verificacion de contrasena tiene exito, el servidor devuelve un flag
totp_required: true. El cliente debe entonces enviar el codigo TOTP en una segunda peticion.
Los codigos de respaldo son codigos de recuperacion de un solo uso, hasheados con Argon2id antes del almacenamiento (el mismo hashing que usamos para contrasenas). Cuando un codigo de respaldo se consume durante el login, se elimina permanentemente de la base de datos. Esto no estaba en el plan original -- la primera implementacion generaba codigos de respaldo en la respuesta de la API pero nunca los persistia. La auditoria de seguridad lo detecto como un hallazgo critico.
El setup_nonce en el paso de confirmacion merece explicacion. Sin el, un atacante que intercepte la respuesta de configuracion podria confirmar TOTP en la cuenta de la victima usando su propia app autenticadora. El nonce vincula la peticion de confirmacion a la sesion de configuracion especifica.
El extractor AuthUser
Las cinco capas convergen en un lugar: el extractor AuthUser de Axum. Aqui es donde la autenticacion realmente ocurre en cada peticion.
rust#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
// Prioridad 1: Token Bearer en header Authorization
if let Some(token) = extract_bearer_token(&parts.headers) {
return verify_jwt(token, &app_state.jwt_secret).await;
}
// Prioridad 2: Cookie HTTP-only sh0_access
if let Some(token) = extract_cookie(&parts.headers, "sh0_access") {
return verify_jwt(token, &app_state.jwt_secret).await;
}
// Prioridad 3: Clave API (prefijo sh0_)
if let Some(key) = extract_bearer_token(&parts.headers) {
if key.starts_with("sh0_") {
return verify_api_key(key, &app_state.db).await;
}
}
Err(ApiError::Unauthorized)
}
}Cualquier handler que necesite autenticacion simplemente anade auth: AuthUser a su lista de parametros. Axum llama al extractor antes de que el handler se ejecute. Si la extraccion falla, el handler nunca se ejecuta -- el cliente obtiene un 401. Este patron significa que la logica de autenticacion se escribe una vez y se aplica en todas partes por el sistema de tipos. Olvidar anadir AuthUser a un handler es la unica forma de crear un endpoint no autenticado, y la revision de codigo lo detecta.
Tambien construimos un extractor OptionalAuth para endpoints que se comportan diferente para usuarios autenticados y anonimos (como health checks que muestran detalles extra a los administradores).
El flujo de configuracion inicial
sh0 no tiene cuenta de administrador por defecto. La primera vez que inicias el binario, el sistema esta en "modo configuracion". El endpoint /api/auth/setup solo esta disponible cuando existen cero usuarios en la base de datos. Acepta un correo y contrasena, crea el usuario administrador, hashea la contrasena con Argon2id, emite un JWT y transiciona el sistema al modo normal. Despues de eso, el endpoint de configuracion devuelve 400 ("Configuracion ya completada").
Esto elimina un problema comun de seguridad con herramientas auto-hospedadas: credenciales por defecto. No hay admin/admin que olvidar cambiar. La primera persona que acceda a la instalacion nueva se convierte en el administrador.
En el lado del CLI, el binario principal auto-genera el archivo de secreto JWT en la primera ejecucion usando 64 bytes de SystemRandom. Tambien maneja la derivacion de la clave maestra desde la variable de entorno SH0_MASTER_PASSPHRASE. El objetivo: ./sh0 deberia funcionar en la primera invocacion con cero configuracion, generando todo el material criptografico automaticamente.
24 pruebas para codigo criptografico
El codigo criptografico que no se prueba es codigo roto que aun no has descubierto. Escribimos 24 pruebas unitarias en sh0-auth cubriendo:
- El hashing de contrasenas produce hashes diferentes para la misma entrada (salts aleatorios)
- La verificacion de contrasenas tiene exito para contrasenas correctas y falla para incorrectas
- La creacion y verificacion de JWT hace ida y vuelta correctamente
- Los JWTs expirados se rechazan
- La generacion de claves API produce el formato de prefijo correcto
- La comparacion de hash de claves API funciona con igualdad en tiempo constante
- El cifrado/descifrado AES-256-GCM hace ida y vuelta
- El texto cifrado manipulado se rechaza
- La generacion y verificacion de codigos TOTP con tolerancia de desviacion de reloj
- El hashing y verificacion de codigos de respaldo
Ademas de esas, 5 pruebas de integracion en sh0-api ejercitan el flujo completo: configuracion, login, recuperacion de perfil, rechazo 401 sin token y autenticacion por clave API. Cada prueba de integracion existente se actualizo para autenticarse a traves del flujo de configuracion primero.
Conteo total de pruebas despues de la Fase 9: 162, todas pasando.
Lo que hariamos diferente
La expiracion JWT inicial de 7 dias era demasiado larga. La auditoria de seguridad la marco como un hallazgo medio -- la recomendacion era 15-30 minutos con tokens de actualizacion. Despues implementamos eso en la migracion a cookies HTTP-only (Articulo 11 de esta serie). Tokens de acceso de corta vida con un token de actualizacion de 30 dias almacenado en una cookie HTTP-only es la arquitectura correcta. La expiracion de 7 dias fue una decision de "hacerlo funcionar" que sabiamos que revisitariamos.
Tambien inicialmente usamos CorsLayer::permissive(), que permite todos los origenes. Aceptable para desarrollo local, inaceptable para produccion. La auditoria lo detecto, y lo reemplazamos con una lista de origenes configurable via la variable de entorno SH0_CORS_ORIGINS.
Conclusiones clave
- Aislar auth en su propio crate. El codigo criptografico se beneficia de un alcance ajustado y pruebas independientes.
- Usar el sistema de tipos para aplicacion. El extractor
AuthUserhace que el acceso no autenticado sea una preocupacion de tiempo de compilacion, no de tiempo de ejecucion. - Nunca almacenar secretos crudos. Las contrasenas obtienen Argon2id. Las claves API obtienen SHA-256. Las variables de entorno obtienen AES-256-GCM. Los secretos TOTP y codigos de respaldo se hashean.
- Auto-generar todo en la primera ejecucion. Sin credenciales por defecto, sin generacion manual de claves, sin archivos de configuracion que crear antes de que el binario funcione.
- La comparacion en tiempo constante no es opcional. Los ataques de timing en claves API son reales y explotables.
Siguiente en la serie: Auditamos nuestra propia plataforma y encontramos 88 problemas de seguridad -- que pasa cuando diriges la auditoria de seguridad hacia ti mismo antes de que alguien mas lo haga.