English · Español
Teoría 03 — Auth y el minivault¶
🇪🇸 La autenticación se descompone en tres preocupaciones independientes: cómo se almacena la contraseña (Argon2id con pepper), cómo se mantiene viva la sesión (cookie firmada con
itsdangerous), y cómo se cifra el archivo en reposo (AES-256-GCM con clave derivada en memoria). Cada una resiste un fallo diferente. Saltarse cualquiera es como ponerse cinturón pero olvidar los frenos.
Las tres preocupaciones¶
Un portal con despliegue local todavía necesita una autenticación que sobreviva a la superficie de ataque realista: alguien lee el archivo SQLite del portátil. La pila de mitigación tiene tres capas independientes:
- Hashing de contraseña (defensa frente a fugas de credenciales si se lee la fila).
- Pepper del servidor (defensa contra la reutilización de rainbow tables entre despliegues).
- Cifrado en reposo del minivault (defensa contra la copia del archivo de DB entero fuera del disco).
Cada capa por separado es insuficiente; juntas dan un coste de compromiso que excede el valor de los datos (el progreso de un learner en un currículo de tutor de gramática). Para el modelo de amenazas de la Fase 37, esta pila cubre T6 (robo de credenciales) y una vía de T8 (exfiltración de la DB).
Hashing de contraseña: Argon2id¶
La política de supply chain de la Fase 37 (véase security/supply-chain.md) fija argon2-cffi y prohíbe PyCryptodome para el hashing de contraseñas. La razón: Argon2id es la recomendación de OWASP para nuevas aplicaciones, ganó la competición PHC, y el binding argon2-cffi está ampliamente auditado.
Los parámetros de Argon2id, según el cheat sheet de almacenamiento de contraseñas de OWASP (revisión 2026):
| Parámetro | Valor | Razón |
|---|---|---|
memory_cost |
64 MiB (65536 KiB) | Satura los ataques con GPU en tarjetas de consumo. |
time_cost |
3 iteraciones | Empírico: hash-y-verify ~50 ms en el i5-8250U de Borja. |
parallelism |
2 | Dos hilos; coincide con el presupuesto típico de auth interactiva. |
hash_len |
32 bytes | Salida de 256 bits; preparado para el futuro. |
salt_len |
16 bytes | Por credencial, aleatorio; almacenado en la fila. |
El coste de verificación de 50 ms es deliberado. Acota los ataques de adivinación online (~20 intentos/segundo/IP) sin volver el login molesto. CI ejecuta el script de calibración tests/integration/test_argon2_calibration.py y asegura 35 ms ≤ verify_time ≤ 80 ms, fallando si los parámetros se desvían de la banda.
El pepper del servidor¶
Además del salt por credencial, el portal aplica un pepper a nivel de servidor a cada contraseña antes de hashearla:
El pepper:
- Vive en la variable de entorno
PORTAL_PEPPER. - Se carga al arrancar la app; nunca se escribe en la base de datos.
- Se rota independientemente de la contraseña de cualquier usuario individual (el procedimiento de rotación re-hashea todas las credenciales perezosamente en el siguiente login).
Por qué el pepper no está en la DB: si el atacante lee el archivo SQLite pero no el entorno del host, los hashes son inverificables. Los salts por sí solos no dan esto — los salts están pensados para estar junto al hash. El pepper amplía la brecha entre "robaron la DB" y "pueden montar una adivinación offline".
El pepper se trata como material secreto: nunca se loggea, nunca se imprime en eco, nunca se commitea. El job de secret-scan de la Fase 37 (regex sobre git diff --staged) atrapa los literales PORTAL_PEPPER=... antes de que entren a la historia de git.
Sin contraseña por defecto¶
El portal soporta un flujo de login inusual: un registro de estudiante puede existir sin contraseña. El flujo:
- El admin crea el registro del estudiante. Inserta en
studentsconrole = 'student'. Inserta encredentialsconargon2_hash = NULL,salt = <16 bytes aleatorios>,must_set_on_next_login = TRUE. El audit log registraadmin_create_student. - El estudiante hace login solo con el username. El endpoint de login revisa
must_set_on_next_login. Si es TRUE, el campo de password se ignora — el estudiante se autentica solo conusername— y la respuesta redirige a/set-password. - El estudiante establece su contraseña. Un POST a
/set-passwordescribeargon2_hash = argon2id(password || pepper, salt),password_set_at = NOW(),must_set_on_next_login = FALSE. El audit log registrapassword_set. - Logins posteriores requieren contraseña. Flujo estándar de verify-contra-hash.
- Recuperación de contraseña. El admin vuelve a marcar
must_set_on_next_login = TRUEdesde la vista de admin. El estudiante hace login solo con username otra vez, establece una contraseña nueva. Sin infraestructura de email. Esta es la decisión explícita fuera de alcance: la autenticación por email es pesada (credenciales SMTP, deliverability, anti-spam, bounce handling) y el portal es para un único learner. El reset del admin es suficiente.
El modelo de amenazas del paso 2 es "cualquiera que adivine el username creado por el admin puede entrar". La mitigación es doble: el username se trata como un secreto débil (se prefieren tokens aleatorios frente a handles adivinables) y el audit log registra cada login, permitiendo al admin detectar anomalías. El MVP acepta este tradeoff porque el despliegue es solo localhost y el modelo de atacante excluye a alguien con acceso físico a la máquina en marcha.
Por qué no hay email¶
El portal no envía email. Tres razones:
- Sin dependencia externa. Añadir
aiosmtplib+ credenciales SMTP + una preocupación de monitorización de deliverability multiplica la superficie operativa para un despliegue de un único learner. - Sin verificación de identidad. El email verifica "esta persona controla este buzón", lo cual es significativo solo cuando el portal tiene datos dignos de proteger más allá de lo que el propio despliegue ya protege. El despliegue local cierra este agujero en la capa de despliegue.
- Sin cadena de recuperación de contraseña. El admin (Borja, en el MVP) puede re-marcar al estudiante en milisegundos. El flujo de reset por email existe para recuperar acceso cuando el admin no está disponible; esa condición no se da aquí.
Si/cuando el portal se hospede, el email se vuelve obligatorio. La ruta de migración está documentada en BLUEPRINT.md §7.
El minivault: cifrado en reposo¶
El archivo de DB (instance/portal.db) se trata como parcialmente confidencial: la mayoría de las columnas no son secretos, pero credentials.argon2_hash y audit_log.details_json son sensibles. El minivault cifra estas columnas específicas en reposo usando AES-256-GCM.
Derivación de la clave¶
La clave del vault nunca se escribe en disco. Se deriva al arrancar la app desde una passphrase maestra cargada desde el entorno:
master_pass = os.environ["PORTAL_MASTER_PASS"]
vault_key = argon2id(master_pass, salt=fixed_salt, hash_len=32) # 256 bits
El fixed_salt es una constante de tiempo de build (compilada en el binario) — no es un secreto por despliegue. Su papel es la separación de dominios, no la entropía. La master pass es la fuente de entropía; el salt evita que la clave derivada colisione con otros contextos Argon2 (el pepper de credenciales, por ejemplo).
La clave del vault vive solo en la memoria del proceso. Al terminar el proceso, desaparece. Reiniciar el portal requiere que PORTAL_MASTER_PASS se vuelva a suministrar. No hay persistencia de la clave del vault, por diseño.
Contrato AES-256-GCM¶
Cada escritura en una columna cifrada pasa por:
nonce = os.urandom(12) # 96 bits
ciphertext, tag = AES_GCM(vault_key).encrypt(nonce, plaintext)
stored = nonce || tag || ciphertext
Cada lectura invierte:
nonce, tag, ciphertext = split(stored, [12, 16, ...])
plaintext = AES_GCM(vault_key).decrypt(nonce, tag || ciphertext)
GCM da cifrado autenticado — modificar el ciphertext invalida el tag. El nonce de 96 bits es fresco por escritura (probabilidad de colisión acotada; el audit log escribe ~10⁴ filas a lo largo de la vida del portal, muy por debajo del límite de cumpleaños de 2⁴⁸ para un nonce de 96 bits).
Qué se cifra, qué no¶
| Campo | ¿Cifrado? | Razón |
|---|---|---|
credentials.argon2_hash |
Sí | Los hashes filtran incluso con salt+pepper. |
audit_log.details_json |
Sí | Contiene IPs de login, query strings, ecos ocasionales de payload. |
students.username |
No | Se busca como clave de lookup primaria; cifrar destruye la utilidad del índice. |
journal_entries.body_markdown |
No | Voluminoso; el coste de cifrar no es trivial; el modelo de amenazas valora la severidad de la fuga en ⅖. |
exam_responses.response_text |
No | Igual. |
La elección es pragmática. Cifrar solo lo que tiene la mayor severidad de fuga por byte. La frontera está documentada en BLUEPRINT.md §4.
Patrón de descifrado en lectura¶
El minivault se invoca desde una capa fina TypeDecorator de SQLAlchemy: las columnas cifradas se tipan como VaultBytes, cifrando transparentemente en __set__ y descifrando en __get__. El código de la aplicación nunca ve bytes en bruto.
Esto significa que un test puede verificar que "la fila en disco contiene ciphertext" bajando a SQLite en bruto y leyendo la columna directamente:
La salida hex debería ser un prefijo de nonce de 12 bytes seguido de un tag de 16 bytes y ciphertext irreconocible — sin marcador Argon2 incrustado ($argon2id$...). El lab de la Fase 41 incluye este test.
Sesiones¶
Las sesiones usan cookies firmadas vía itsdangerous, no tablas de sesión en el servidor para el camino habitual. La cookie lleva:
student_idcsrf_tokenexpires_at
… firmados con una clave HMAC del servidor (env PORTAL_SESSION_SECRET). La firma se verifica en cada request; manipular invalida la cookie.
Una fila paralela en sessions existe en la DB (según el modelo de datos), pero su propósito es la revocación — listar sesiones activas, permitirle al admin terminar una. La propiedad de bearer token de la cookie hace que la revocación no sea gratis; el índice sessions.token_hash hace lookups O(log N) por request, lo cual está bien para la carga de un único learner.
Atributos de la cookie:
HttpOnly— JavaScript no puede leer la cookie. Defensa contra robo de sesión por XSS.Secure— se envía solo por HTTPS. El modo dev del MVP en localhost lo desactiva vía env; producción lo tiene activado.SameSite=Strict— defensa contra cross-site request forgery para operaciones que cambian estado.
La expiración rolling de 30 días: cada request adelanta expires_at 30 días desde ahora. La inactividad durante 30 días expira la sesión. Se aplica también un max-age absoluto de 90 días — incluso con actividad continua se cierra sesión a los 90 días, forzando re-auth.
Tokens CSRF¶
Cada ruta que cambia estado (POST, PUT, DELETE) requiere un token CSRF enviado en el body del request o en la cabecera X-CSRF-Token. El token se genera en el servidor, se firma, y se expone en la respuesta de cada GET. SameSite=Strict provee una defensa paralela; los tokens CSRF son cinturón-y-tirantes.
Filtro de inyección¶
El filtro de inyección de body_markdown de la Fase 37 se aplica a cada escritura en una columna que lleve markdown (journal_entries.body_markdown, notes.body_markdown, exam_responses.response_text). El filtro:
- Elimina caracteres de anchura cero (U+200B, U+200C, U+FEFF).
- Rechaza secuencias que parecen prefijos de prompt injection (
<!--,### SYSTEM:,<|im_start|>). - Limita el tamaño a 100 KiB por escritura (guarda a nivel de DB contra inputs patológicos).
La amenaza T8 de la teoría 03 de la Fase 37 (almacenamiento de trazas como vector de inyección) queda cerrada por este filtro — el contenido almacenado en la DB ya no es un vector si más tarde se vuelve a alimentar a un LLM.
Lo que este diseño de auth NO hace¶
- Sin 2FA. TOTP / WebAuthn fuera de alcance. El modelo de amenazas no justifica la complejidad a escala de un único learner.
- Sin OAuth. Sin "Iniciar sesión con Google". Añadirlo significa una dependencia externa y una relación de confianza fuera de banda.
- Sin reglas de complejidad de contraseñas. La guía de OWASP (2026) recomienda explícitamente no aplicar reglas de complejidad — reducen la entropía en la práctica al empujar a los usuarios a patrones predecibles. El portal solo impone una longitud mínima (12 caracteres) y usa la verificación lenta de Argon2 como rate limiter.
- Sin comprobación de contraseñas filtradas. Adición futura; llamaría a la API de k-anonimato de
haveibeenpwned, pero la dependencia de red se rechaza en el MVP.
Recapitulación en un párrafo¶
Argon2id con coste de memoria de 64 MiB y un pepper de servidor gestiona el hashing de contraseñas. El flujo "sin contraseña por defecto" permite al admin hacer onboarding de un learner sin fuga de contraseña temporal. Un minivault cifra la columna de hash y el audit log en reposo usando AES-256-GCM con una clave derivada en memoria desde una passphrase maestra. Las sesiones son cookies firmadas con HttpOnly + Secure + SameSite=Strict y una fila paralela en la DB para revocación. Los tokens CSRF y el filtro de inyección de la Fase 37 cierran los caminos restantes de ataque a nivel HTTP.
Siguiente: theory/04-ux.md — tres personas, cuatro pantallas, wireframes ASCII.