English · Español
Teoría 05 — Modelo de amenazas del portal (mirando hacia la Fase 41)¶
🇪🇸 La Fase 37 enseña amenazas contra el tutor de gramática como sistema adversarial (inyecciones, RAG envenenado, fuzz de tools). La Fase 41 hereda esas amenazas y añade las propias de un portal multi-usuario: límite authn/authz entre estudiantes y admin, cookies firmadas vs sesiones en servidor, política sin contraseña por defecto, y elección de parámetros Argon2id en un i5-8250U.
Por qué existe este capítulo¶
Los primeros cuatro capítulos de teoría de la Fase 37 modelan amenazas contra el agente — qué pasa cuando un usuario escribe ignore previous instructions, o cuando el índice RAG se envenena, o cuando un argumento de tool escapa del sandbox. Esas amenazas aplican dentro de la frontera del modelo. El portal de la Fase 41 está fuera de esa frontera: es una aplicación HTTP multi-tenant que entrega el currículo a muchos estudiantes con un rol de profesor/admin. Su superficie de ataque es la superficie HTTP — credenciales, sesiones, CSRF, replay, claves en memoria — no prompts.
Este capítulo enseña las cuatro preguntas de modelo de amenazas relevantes para el portal que la Fase 37 le debe a la Fase 41. Cada una es una pequeña lección por sí sola; el portal las compone en docs/phase-41-learner-portal/theory/03-auth-and-vault.md. La división es deliberada: la Fase 37 es vocabulario de modelado de amenazas, la Fase 41 es modelado de amenazas aplicado a una app multi-usuario concreta.
La disciplina de enlaces cruzados refleja theory/04-portal-building-blocks.md de la Fase 33: cada sección nombra el sitio de uso del portal y el capítulo de arquitectura que compone las piezas.
§1 — La frontera authn/authz: journal del estudiante vs vista del admin¶
La autenticación responde a "quién eres" (la cookie de sesión resuelve a un student_id). La autorización responde a "qué puedes hacer" (una columna role dice student o admin). Los principiantes funden las dos y acaban con el bug canónico — un endpoint de estudiante que confía en el parámetro de la URL (/journal/{student_id}) en vez del id resuelto por la sesión, permitiendo que el estudiante A consulte el journal del estudiante B cambiando la ruta. La defensa del portal es una única fuente de verdad: el id de sesión, y sólo el id de sesión, identifica al estudiante que solicita; la URL identifica al objetivo, y la capa de autorización compara ambos.
Para la Fase 41 hay exactamente dos activos y dos roles de actor. Los activos son el journal/notas/intentos de examen del estudiante (cada uno con alcance a un estudiante) y la vista de progreso del admin (que atraviesa a todos los estudiantes). Los actores son estudiante y profesor/admin. La matriz legal es pequeña: estudiante → activo propio (permitir), estudiante → activo de otro estudiante (denegar, 404 para evitar fuga de existencia), estudiante → vista de admin (denegar, 403), admin → cualquier activo (permitir, auditado). Cada ruta protegida vincula Depends(require_student) o Depends(require_admin); este último además escribe una fila AuditEvent por cada lectura mediada por el admin. Ese borde de auditoría es la defensa en profundidad — incluso una lectura autorizada del admin queda registrada para que una cuenta de admin comprometida deje rastro.
@app.get("/journal/{ymd}")
def journal_read(
ymd: str,
student: Student = Depends(require_student), # session = source of truth
db: Session = Depends(get_db_session),
):
# NEVER trust a {student_id} URL segment; always use student.id from session
entry = db.exec(
select(JournalEntry).where(
JournalEntry.student_id == student.id,
JournalEntry.on_date == parse(ymd),
)
).first()
if entry is None:
raise HTTPException(404) # not 403 — avoid existence leak
return render(entry)
Referencia hacia adelante: los invariantes de aislamiento por-estudiante del portal viven en src/miniportal/BLUEPRINT.md §6 y los valida tests/test_miniportal_isolation.py; ver también docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"The three concerns".
§2 — Cookies firmadas vs sesiones en el servidor¶
Una sesión puede vivir en dos sitios: firmada en el cliente (una cookie autocontenida que el servidor verifica con una clave HMAC) o almacenada en el servidor (una fila en una tabla sessions, donde la cookie sólo lleva un id opaco). El compromiso es ajustado. Las cookies firmadas son stateless — no hay consulta a la base de datos por request, no hay cache, ni preocupación por replicación. La pega es la revocación: invalidar una cookie firmada implica o bien rotar la clave del servidor (cierra la sesión a todos) o mantener una deny-list (reintroduciendo estado en el servidor, derrotando el propósito). Las sesiones en servidor cuestan una consulta a la base de datos por request, pero la revocación es un DELETE FROM sessions WHERE id = ?.
Para un portal de un solo proceso y ≤ 50 estudiantes sobre SQLite, la consulta a la base de datos es gratis (< 100 µs por request) y la historia operacional es mucho más simple. Por tanto, el portal usa ambos: las cookies van firmadas (de modo que una cookie manipulada se rechaza sin un hit a la base de datos) y existe una tabla de sesiones para revocación. Los atributos de la cookie son innegociables: HttpOnly (frustra el robo vía XSS), Secure (no hay fuga en texto plano), SameSite=Strict (defensa CSRF paralela). La clave HMAC de sesión se carga al arrancar desde PORTAL_SESSION_SECRET; rotarla es la opción nuclear (cierra la sesión a todos), y el procedimiento de rotación se documenta en el capítulo auth/vault.
def issue_session(student: Student, signer: itsdangerous.Signer) -> str:
payload = json.dumps({"sid": student.id, "exp": now() + 3600})
return signer.sign(payload).decode() # cookie body
def verify_session(token: str, signer: itsdangerous.Signer) -> int:
payload = signer.unsign(token, max_age=3600) # raises on tamper / expiry
return json.loads(payload)["sid"]
Referencia hacia adelante: las elecciones de atributos de sesión del portal, incluyendo el override Secure=False para dev y la política de expiración rolling de 30 días, se explican en docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"Sessions".
§3 — La política de sin-contraseña-por-defecto¶
El flujo de onboarding del portal es no estándar y relevante para la seguridad. Cuando un admin crea un estudiante, la fila de credenciales se inserta con password_hash = NULL y se genera un invite token de un solo uso. El estudiante visita /invite/{token}, el servidor comprueba que el token sea válido y no canjeado, y renderiza un formulario set-password. Al hacer POST del formulario se escribe el hash Argon2id y se marca el token como consumido. Nunca se transmite una contraseña temporal; el secreto que un estudiante debe poseer para activar su cuenta es el token firmado y con tiempo limitado en la URL de invitación.
La superficie de ataque que esto expone es estrecha pero real, y la Fase 41 debe defender cada camino explícitamente. (a) Replay de token: un atacante que vea la URL de invitación una vez no debe poder usarla dos veces — la columna used_at del token se establece bajo un constraint UNIQUE, y el segundo canje devuelve 410 Gone. (b) Fuerza bruta de token: el token son 32 bytes aleatorios firmados con itsdangerous; el verificador comprueba la firma antes del lookup en la base de datos, de modo que la fuerza bruta nunca llega a la base de datos. © Expiración del token: los tokens llevan expires_at (por defecto 24 h) que el verificador comprueba; los tokens expirados renderizan una página "pídele a tu profesor una nueva invitación". (d) Fuga del token por referrer: la página de invitación fija Referrer-Policy: no-referrer para que seguir un enlace externo desde el formulario set-password no filtre el token. La mitigación completa está codificada en lab/05-portal-replay.md de la Fase 41 — Borja ejecuta los tres ataques de demostración (replay, expirado, manipulado) y confirma que cada uno devuelve el código de estado correcto.
@app.post("/invite/{token}")
def redeem_invite(
token: str,
new_password: str = Form(...),
db: Session = Depends(get_db_session),
):
try:
nonce = signer.unsign(token, max_age=86400).decode()
except BadSignature:
raise HTTPException(403)
invite = db.exec(
select(InviteToken).where(InviteToken.nonce == nonce, InviteToken.used_at.is_(None))
).first()
if invite is None:
raise HTTPException(410) # already used or revoked
invite.used_at = now()
student = db.get(Student, invite.student_id)
student.password_hash = argon2_hash(new_password, pepper)
db.commit()
Referencia hacia adelante: la política se describe en docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"No-password-by-default"; el lab que la ejercita es docs/phase-41-learner-portal/lab/01-passwordless-first-login.md.
§4 — Elección de parámetros Argon2id en el i5-8250U¶
Los parámetros de Argon2id (memory_cost, time_cost, parallelism, hash_len) compensan latencia del verificador frente a coste para el atacante. La recomendación 2026 de OWASP es 64 MiB de memoria / 3 iteraciones / 2 hilos como punto de partida; el portal debe calibrar contra el hardware real (el i5-8250U de Borja, Kaby Lake R, 4C/8T, 2018) porque la recomendación asume una CPU de clase servidor. El objetivo de calibración es tiempo de verificación en [35, 80] ms — lo bastante rápido para que el login se sienta interactivo, lo bastante lento para que adivinación online (sin un rate limit aparte) cueste ~20 intentos/segundo/IP.
De los cuatro parámetros, memory_cost domina las decisiones de modelo de amenazas. ¿Por qué? Porque los atacantes acelerados por GPU (la amenaza realista de adivinación offline) están limitados por la RAM de GPU y el ancho de banda PCIe, no por el reloj de la CPU. Un working set de 64 MiB satura las caches L2 de GPUs de consumo; duplicar a 128 MiB duplica el coste de hardware del atacante pero sólo añade ~30 ms a una verificación en un host con 4 GiB. time_cost es lineal en el coste del atacante y del defensor (sin asimetría). parallelism es mayormente cosmético en un portátil de 4 cores. Por tanto, el script de calibración fija time_cost=3 y parallelism=2, luego barre memory_cost sobre {16, 32, 64, 128} MiB hasta acertar la banda de tiempo de verificación. En el i5-8250U de Borja la respuesta empírica es 64 MiB → ~50 ms; la curva se traza en experiments/41-argon2-calibration/curve.png. El test de CI tests/integration/test_argon2_calibration.py asegura que el tiempo de verificación se mantenga en banda en cada commit, fallando si una actualización de kernel o una release de argon2-cffi cambia el rendimiento lo bastante como para salir de la ventana.
from argon2 import PasswordHasher, Type
PH = PasswordHasher(
type=Type.ID,
memory_cost=65536, # 64 MiB — the load-bearing parameter
time_cost=3,
parallelism=2,
hash_len=32,
salt_len=16,
)
def hash_password(plaintext: str, pepper: bytes) -> str:
return PH.hash(pepper + plaintext.encode("utf-8"))
def verify_password(plaintext: str, stored: str, pepper: bytes) -> bool:
try:
return PH.verify(stored, pepper + plaintext.encode("utf-8"))
except (VerifyMismatchError, InvalidHashError):
return False
Referencia hacia adelante: la discusión completa de calibración, incluyendo el aserto de banda de tests/integration/test_argon2_calibration.py y la ruta de rotación cuando CI marca un drift, vive en docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"Password hashing: Argon2id".
Referencia cruzada a la Fase 41¶
| Vocabulario de la Fase 37 (este capítulo) | Sitio de uso en la Fase 41 |
|---|---|
| Authn vs authz, sesión como fuente-de-verdad (§1) | src/miniportal/auth.py::require_student, tests/test_miniportal_isolation.py |
| Cookies firmadas + sesiones en DB para revocación (§2) | src/miniportal/auth.py::issue_session, modelo Session |
| Sin contraseña por defecto + invite token (§3) | src/miniportal/routes/auth.py::redeem_invite, lab 01 / lab 05 |
| Calibración memory-cost de Argon2id (§4) | src/miniportal/auth.py::hash_password, experiments/41-argon2-calibration/ |
Cada amenaza específica del portal en security/THREATS.md (filas T para replay de invite token, CSRF en el widget de notas, abuso de set-password, contraseñas débiles por defecto, clave del vault en memoria, manipulación del audit-log) se mapea a una de estas cuatro secciones. El lab/05-security-replay.md del portal ejercita las verificadas en runtime; las verificadas a nivel de diseño se validan por revisión de código.
Siguiente: lab/00-prompt-injection-direct.md vuelve a la preocupación principal de la Fase 37 — el ataque del payload pirata contra el tutor de gramática.