0020. Params de Argon2id y expiración de tokens¶
Estado: Propuesto Fecha: 2026-09-08 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad (hashing y emisión de tokens)
Contexto¶
El stack eligió Argon2id para hashing (0002) y el login definió tokens access/refresh/pre_token (0010, 0011) con expiración "por defecto 30 días" para el refresh. Quedaron como deuda los números concretos: parámetros de Argon2id (que son quienes hacen que un hash sea caro de atacar) y los TTLs de cada token. Este ADR los fija con valores de 2026, no los mínimos de 2015.
Decisión¶
1. Parámetros de Argon2id¶
| Parámetro | argon2-cffi |
Valor | Notas |
|---|---|---|---|
| Memory | memory_cost |
64 MiB (65536 KiB) | 3× el piso del RFC 9106 (19 MiB) |
| Iteraciones | time_cost |
3 | |
| Paralelismo | parallelism |
1 | la app es Python/single-thread por request; no gana con lanes altas |
| Longitud hash | hash_len |
32 bytes | |
| Longitud salt | salt_len |
16 bytes |
# core/security.py
from argon2 import PasswordHasher
from argon2.profiles import RFC_9106_HIGH_MEMORY # 64 MiB, t=3, p=1 (o definir manual)
ph = PasswordHasher(
memory_cost=65536, # 64 MiB
time_cost=3,
parallelism=1,
hash_len=32,
salt_len=16,
)
Los tres están sobre-escribibles por entorno (
ARGON2_MEMORY_KIB,ARGON2_TIME_COST,ARGON2_PARALLELISM) para ajustarlos sin tocar código. El hash Argon2 almacena sus propios parámetros ($argon2id$v=19$m=65536,t=3,p=1$...), así que subir la memoria después es transparente: los hashes viejos se siguen verificando y el login exitoso los re-hashea con los params nuevos (patrón rehash on login).
2. TTLs de los tokens¶
| Token/entidad | TTL | Variable de entorno | Notas |
|---|---|---|---|
access_token (JWT) |
15 min | ACCESS_TOKEN_TTL_MIN |
stateless (0006): corto porque no se revoca individualmente |
refresh_token |
30 días | REFRESH_TOKEN_TTL_DAYS |
sí se revoca (0005/0011); rotación por uso |
pre_token |
5 min | PRE_TOKEN_TTL_MIN |
0010/0018: solo permite elegir empresa |
aud / iss |
— | JWT_ISSUER = com.sigfa.identidad · JWT_AUDIENCE = com.sigfa.plataforma |
referencias del claim de 0011 |
La audiencia y el emisor son fijos para toda la plataforma: un token emitido
por identidad solo se acepta si aud == "com.sigfa.plataforma"; evitar que un
token de un servicio-hermano se valide en otro (defensa en profundidad sobre el
RS256 de 0006).
3. Locales de expiración y clockskew¶
exp/iatsiempre en UTC (datetime.now(timezone.utc)).- Los validadores toleran ≤ 30 s de desfase de reloj (
JWT_CLOCK_SKEW_SEC). - Resolución del refresh: cada uso rota (0011), el viejo se revoca y
expregula el máximo de vida de la sesión; no hay "sliding forever".
4. Audiencia fija y validación (cómo funciona hoy)¶
iss (com.sigfa.identidad) y aud (com.sigfa.plataforma) son fijos
para toda la plataforma: un mismo access token sirve en todos los servicios
que validan contra el JWKS. La separación real entre servicios la dan
empresa_id (RLS) y permisos, no el aud (decisión que reemplaza al
"audience por servicio" del 0011 §3a).
Analogía (edificio y departamentos). La plataforma es un edificio y
cada microservicio es un departamento (ERP, CRM, RRHH, pedidos…). Recepción
(svc-identidad) entrega una credencial (el token) que dice:
| Dato | Qué significa | Valor |
|---|---|---|
iss (issuer) |
el sello de quién la emitió | com.sigfa.identidad |
aud (audience) |
para qué edificio es válida | com.sigfa.plataforma |
scope / permisos |
qué puertas abre dentro del edificio | según el usuario |
Como todos los departamentos están en el mismo edificio, todos aceptan la
misma aud (no hay una por microservicio). Los visitantes externos
(Aylen, Siigo, QuickBooks) no son empleados: reciben una credencial de
visitante con su propia aud (com.sigfa.integraciones.<nombre>) y
scope limitado, para que no entren a donde no deben.
La distribución de la clave pública por JWKS y la configuración
.envde cada tipo de servicio (emisor, interno, externo) están en el ADR 0006 (transversal).
flowchart LR
U["Frontend"] -->|"login (2 pasos)"| ID["svc-identidad<br/>(único emisor)"]
ID -->|"firma con clave PRIVADA"| TOK["access_token<br/>iss=com.sigfa.identidad<br/>aud=com.sigfa.plataforma"]
ID -->|"publica SOLO públicas"| JWKS["/.well-known/jwks.json"]
TOK --> SVC["Servicio (ERP / CRM / …)"]
JWKS -->|"baja 1 vez y cachea"| SVC
SVC -->|"valida firma + exp + iss + aud"| OK["Atiende con empresa_id (RLS)<br/>+ permisos del token"]
5. Cambio de claves: filtrada o corrupta¶
¿Es posible cambiar la clave? Sí, en ambos casos. La clave RSA se puede regenerar en cualquier momento; lo que cambia es la urgencia y qué hacer con los tokens ya emitidos:
- Filtrada (alguien obtuvo la privada): es un compromiso → rotación de emergencia inmediata (ADR 0017 §3), sin ventana de gracia.
- Corrupta (archivo ilegible): no compromete la seguridad (nadie la robó), pero impide firmar. Se restaura de la bóveda/backup; si no se puede, se trata como filtrada y se rota.
- Los access tokens son stateless: no se revocan, pero viven ≤ 15 min, así que el hueco máximo es corto.
- Los refresh tokens sí se revocan (están en
trx_refresh_tokens) → fuerza relogin de inmediato.
flowchart TD
A["Problema con la clave privada"] --> B{"¿Qué pasó?"}
B -->|"Filtrada (comprometida)"| C["Rotar YA"]
B -->|"Corrupta (no robada)"| D["Restaurar de la bóveda/backup"]
D -->|"Éxito"| E["Operar con la clave restaurada"]
D -->|"No se puede"| C
C --> F["1. Generar clave RSA-2048 nueva + kid nuevo"]
F --> G["2. Publicar la nueva en el JWKS"]
G --> H["3. JWT_CURRENT_KID = nueva"]
H --> I["4. Retirar la comprometida del JWKS<br/>de inmediato (sin gracia)"]
I --> J["5. Revocar TODOS los refresh (logout-all masivo)"]
J --> K["6. Archivar la comprometida cifrada (forense)"]
K --> L["Los access vivos mueren solos en ≤ 15 min"]
El runbook completo (estados
active/retiring/retired, rotación programada cada 90 días, monitoreo y ventana de gracia) está en el ADR 0017; aquí queda el "qué hacer" ante filtración o corrupción.
Alternativas descartadas¶
- Parámetros del RFC 9106 mínimo (19 MiB, t=2) — en 2026 es el piso de interop, insuficiente como blanco de seguridad; 64 MiB casi no encarece el login (una verificación ~200–400 ms) y sube ~3× el coste del atacante.
- bcrypt/PBKDF2 — ya descartados en el 0002/0011 (ganador del PHC).
memory_cost=19 MiBconparallelism=4— el paralelismo no encarece al atacante tanto como la memoria; en app single-thread 1 lane basta.- Access token de 1 h o más — ventana de compromiso larga para un token que no se puede revocar en el momento (stateless); 15 min son una vida de acceso razonable.
- Refresh sin
exp(vida infinita con rotación) — sesiones muertas vivas para siempre; la renovación exige un login real cada 30 días. - TTLs hardcodeados en el código — no auditable ni ajustable por entorno;
todo va por variable de entorno con default en el
pyproject/.env.example.
Consecuencias¶
- Login típico: ~200–400 ms extra por la verificación Argon2id 64 MiB — se paga una vez por login (nunca por request); los access de 15 min validan solo firma.
- Subir el coste de hashing después del primer deploy es gratis en runtime: rehash-on-login migra los hashes viejos progresivamente.
- Todos los TTLs y la audiencia están parametrizados y documentados en un solo lugar; el ajuste de seguridad no requiere cambio de código.
- Costo: cada verificación usa 64 MiB de RAM por proceso → archivar el
PasswordHashercomo singleton (no instanciar por request).
Este ADR cierra las deudas de 0002 (params de Argon2id) y 0011
(expiración real de tokens). El pre_token opera según el 0018; la firma y
el kid según el 0011/0017; la purga de los registros que estos TTLs
hacen caducar está en el 0021.