Saltar a contenido

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/iat siempre 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 exp regula 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 .env de 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 MiB con parallelism=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 PasswordHasher como 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.