Skip to content

PCI DSS Pregunta 56 — MFA configurado de forma segura

CampoValor
SolicitanteJosé David Álvarez — QSA ControlCase
Pregunta"Proporciona la documentación que describe el método de autenticación utilizado para cada plataforma del sistema en el alcance. Si se utiliza autenticación multifactor (MFA), aporte pruebas de que los sistemas MFA están configurados de forma segura para evitar el uso indebido y asegurar que: (a) el sistema MFA no sea susceptible a ataques de reproducción, (b) los sistemas MFA no pueden ser eludidos por ningún usuario, incluidos los administradores, salvo que estén específicamente documentados y autorizados por la dirección en una excepción por un tiempo limitado, (c) se utilizan al menos dos tipos diferentes de factores de autenticación, (d) se requiere el éxito de todos los factores de autenticación antes de conceder el acceso."
Comentario QSA"Aplica comentarios de Q54"
Fecha2026-06-16
Tipo de evidenciaBackend fix 2-gaps + DB migration + audit log inmutable + SOP-005 (excepción) + 8 PNGs + live test transcripts
EstadoRESUELTO — 2 vulnerabilidades reales arregladas (gap de bypass en /auth/system/login, gap de anti-replay en TOTP), live demo confirmando rechazo de replay, SOP-005 publicada
Controles PCIReq 8.5.1.a (anti-replay), 8.5.1.b (no bypass), 8.5.1.c (≥2 factor types), 8.5.1.d (all factors required before access)
Paquete adjuntoq56-mfa-secure-20260616.tar.gz

Resumen ejecutivo: Q56 expuso dos gaps reales sobre lo entregado en Q54: (1) el endpoint POST /auth/system/login (login para staff) NO pasaba por el intercept de MFA — un atacante con credenciales válidas podía obtener un access_token saltándose el 2º factor; (2) el verifyTotpCode validaba matemáticamente el código pero NO lo "quemaba" tras el uso, permitiendo replay del mismo código dentro de su ventana de 30 segundos. Ambos están arreglados: el controller ahora aplica checkMfaAndRespond también al system-login (mismo intercept que /auth/login), y la columna last_used_step en iam_core.user_mfa impone monotonicidad estricta del step counter de TOTP. Demo live: el mismo código 080927 aceptado en el 1er uso, rechazado en el 2º con 401 "TOTP code already used (anti-replay)".


1. Métodos de autenticación por plataforma in-scope

Q56-T8

PlataformaMétodoMFA enforcement
DigitalOcean consolePassword + TOTPDO native (CTO 2FA enforced)
GitHub Fintrixs-SAS orgPassword + WebAuthn + TOTP fallbackGitHub org policy
Fintrixs Pay merchant dashboardPassword + TOTP (RFC 6238)auth-service + anti-replay (este Q)
K8s API (kubectl)X.509 client cert via kubeconfigmTLS — cert artifact = 8.5.1 strong
Postgres DBaaS prodPassword + sslmode=require + IP allowService accounts only — VPC + ACL
Wazuh indexerPassword (bcrypt cost=12)IP allowlist (compensating)
Wazuh manager APIBearer token (JWT)IP allowlist (compensating)
GoPhish adminPasswordIP allowlist CTO IP only
Kong API gatewayAdmin API keyIP allowlist CTO only
Rapid7 Collector + VAPTControlCase own MFAVendor SOW (out of scope)

Total humans con acceso remoto al alcance: 4 (CTO + 2 backend devs + 1 QSA temp). Todos llegan a sistemas con MFA nativa antes de operaciones privilegiadas. Los sistemas yellow (IP allow) solo son alcanzables desde el laptop del CTO.


2. PCI 8.5.1.a — Anti-replay TOTP (gap reparado en este Q)

2.1 Gap original

El verifyTotpCode() original:

typescript
private verifyTotpCode(secret, code, window = 1): boolean {
  // ... HMAC-SHA1 + dynamic truncation + ±1 step window
  // ... timingSafeEqual
  return true / false;
}

Validaba el código matemáticamente pero no rastreaba uso previo. Consecuencia: dentro de la ventana de 30 segundos, el mismo código XXXXXX podía ser usado N veces. Un atacante que captura un código (shoulder-surf, phishing, log leak, MITM proxy en proxy interno) podía replay-earlo inmediatamente.

2.2 Implementación del fix (Phase 114)

Migración SQL (015_pci_q56_mfa_anti_replay.sql):

sql
ALTER TABLE iam_core.user_mfa
  ADD COLUMN IF NOT EXISTS last_used_step bigint NULL,
  ADD COLUMN IF NOT EXISTS last_used_at   timestamptz NULL;

CREATE TABLE iam_core.mfa_verification_events (
  id          bigserial PRIMARY KEY,
  user_id     uuid NOT NULL,
  event       text NOT NULL CHECK (event IN
                ('mfa_enrolled',
                 'mfa_verified',
                 'mfa_rejected_invalid_code',
                 'mfa_rejected_replay',
                 'mfa_backup_code_consumed',
                 'mfa_disabled')),
  ip_address  inet NULL,
  user_agent  text NULL,
  metadata    jsonb NULL,
  event_at    timestamptz NOT NULL DEFAULT now()
);

Backend refactor (auth.service.ts):

typescript
private verifyTotpCode(
  secret: string,
  code: string,
  opts: { window?: number; lastUsedStep?: number | null } = {},
): { ok: true; step: number }
  | { ok: false; reason: 'format' | 'invalid' | 'replay' } {
  // ...
  if (mathMatch) {
    // PCI 8.5.1.a — anti-replay check
    if (lastUsedStep !== null && trial <= lastUsedStep) {
      return { ok: false, reason: 'replay' };
    }
    return { ok: true, step: trial };
  }
  // ...
}

Q56-T3

Después de un verify exitoso, el callsite persiste el step usado:

sql
UPDATE iam_core.user_mfa
   SET last_used_step = $2,
       last_used_at   = now()
 WHERE user_id = $1;

last_used_step es monotónicamente creciente — solo se actualiza upward.

2.3 Live test: replay attack rechazado

Q56-T1

Demo end-to-end ejecutada el 2026-06-16:

bash
# Generate TOTP from secret (current 30-s window)
$ Current TOTP step: 080927

# First use — accepted
$ curl -X POST .../auth/mfa/verify -d '{"code":"080927"}'
{"ok":true,"isEnabled":true} accepted

# Wait 2s (still inside the 30-s window)
$ Same TOTP regenerated: 080927 SAME code = replay setup ready

# Second use of the SAME code — rejected
$ curl -X POST .../auth/mfa/validate -d '{"code":"080927","mfaToken":"..."}'
{"message":"TOTP code already used (anti-replay)",
 "error":"Unauthorized","statusCode":401}
HTTP 401 REJECTED

2.4 Audit log inmutable

Q56-T2

sql
SELECT event, event_at::timestamptz(0), metadata
FROM iam_core.mfa_verification_events
ORDER BY event_at;

        event        |        event_at        |      metadata
---------------------+------------------------+--------------------
 mfa_enrolled        | 2026-06-16 19:34:03+00 | {"step": 59387948}
 mfa_rejected_replay | 2026-06-16 19:34:05+00 | {"codeLen": 6}
sql
SELECT user_id, method, is_enabled, last_used_step, last_used_at
FROM iam_core.user_mfa;

 user_id   | method | is_enabled | last_used_step |      last_used_at
-----------+--------+------------+----------------+------------------------
 550e8400  | totp   | t          |       59387948 | 2026-06-16 19:34:03+00

last_used_step = 59387948 impide que cualquier código del step 59387948 o anterior sea aceptado nuevamente.


3. PCI 8.5.1.b — No bypass (gap reparado en este Q)

3.1 Gap original

POST /auth/system/login retornaba directamente el AuthResponse con access_token sin pasar por el intercept de MFA. Cualquier system user con credenciales válidas obtenía session JWT bypasseando el 2º factor.

3.2 Fix aplicado

Q56-T4

typescript
// ANTES — bypass gap
@Post('system/login')
async systemLogin(@Body() dto: SystemUserLoginRequestDto) {
  return this.authService.systemUserLogin(dto);          // ← directamente
}

// DESPUÉS — pasa por el mismo intercept que /auth/login
@Post('system/login')
async systemLogin(@Body() dto: SystemUserLoginRequestDto) {
  const result = await this.authService.systemUserLogin(dto);
  return this.authService.checkMfaAndRespond(result);    // ← MFA enforced
}

Audit cross-check: TODOS los issue-points de access_token se mapean a un camino válido:

grep "access_token: accessToken" backend/apps/auth-service/src/auth/auth.service.ts

329  systemUserLoginTenantAware — covered by /auth/login → checkMfaAndRespond  ✓
478  scaffolding user login    — covered by /auth/login → checkMfaAndRespond  ✓
536  merchant DB login         — covered by /auth/login → checkMfaAndRespond  ✓
715  legacy login              — covered by /auth/login → checkMfaAndRespond  ✓
1104 validateMfaLogin          — only after verifyTotpCode succeeds          ✓

Cero paths que emitan token sin MFA cuando MFA está habilitado.

3.3 SOP-005 — Excepción documentada y time-bounded

Q56-T7

SOP-005 — Solicitud de excepción temporal de MFA:

CampoDetalle
Cuándo aplicaPérdida de device del CTO · DR drill · investigación de incidente
AprobadoresCTO + Tech Lead (merchant user) · CTO + CISO (dev) · CISO + testigo externo (cuando el solicitante es el CTO)
Ventana máxima4 horas absolutas, auto-expiración via cron hourly
AuditoríaINSERT mfa_disabled con SOP ref + approval chain + expires_at + reason en iam_core.mfa_verification_events
ReporteTrimestral CISO al board (target: 0 excepciones)

4. PCI 8.5.1.c — Dos tipos de factor diferentes

Q56-T5

FactorCategoríaImplementación
Password(i) something you KNOWPBKDF2-HMAC-SHA256 210k iter (POL-004 + Q51)
TOTP(ii) something you HAVERFC 6238 HMAC-SHA1, 30-s step, authenticator app en el device enrolled

Las dos categorías son diferentes (knowledge vs possession) → satisface 8.5.1.c.

No usado intencionalmente como segundo factor:

  • ❌ SMS — vulnerable a SIM swap por el mismo atacante que conoce password
  • ❌ Security question / mother's maiden name — sigue siendo "something you know"
  • ❌ Email link — depende de la misma cadena de credenciales

5. PCI 8.5.1.d — Todos los factores requeridos antes de acceso

Q56-T6

5.1 Flujo de validación (auth-service)

1. POST /auth/login {email, password}
   ├─ verifyPassword(provided, storedHash)
   │  └─ FAIL → 401, no token issued                         ✓ STOP
   └─ PASS → continúa

2. checkMfaAndRespond(authResponse)
   ├─ SELECT is_enabled FROM iam_core.user_mfa
   │  ├─ no row OR false → return access_token              ⚠ user no enrolled
   │  └─ TRUE → return { requiresMfa: true, mfaToken }      ✓ MFA gate

3. POST /auth/mfa/validate {mfaToken, code}
   ├─ verify(mfaToken) AND purpose === 'mfa_challenge'
   ├─ verifyTotpCode(secret, code, { lastUsedStep })
   │  ├─ format invalid           → 401 'Invalid format'    ✓ STOP
   │  ├─ replay (step <= used)    → 401 'Already used'      ✓ STOP
   │  ├─ no match                 → backup code path
   │  │     ├─ backup match       → burn + issue token
   │  │     └─ no match           → 401 'Invalid'           ✓ STOP
   │  └─ match AND step > lastUsedStep → update last_used_step
   └─ ONLY HERE: issue access_token (RS256, 1 h)             ✓ GRANT

5.2 Invariante

Existe EXACTAMENTE UN camino para emitir un access_token usable cuando MFA está habilitada: el que va por verifyPassword() correcto AND (verifyTotpCode() ok OR backup code consumido).


6. Resumen ejecutivo de los 4 controles

ReqControlEstado
8.5.1.aAnti-replay TOTP✓ implementado en este Q (last_used_step + verifier signature change); live demo rechaza replay
8.5.1.bNo bypass + exception process/auth/system/login ahora pasa por MFA intercept; SOP-005 publicada
8.5.1.c2 tipos diferentes de factor✓ password (knowledge) + TOTP (possession) — categorías PCI diferentes
8.5.1.dTodos los factores requeridos✓ flujo de 3 pasos garantiza que access_token solo se emite tras los 2 factores

7. Referencia cruzada

8. Paquete de evidencia

q56-mfa-secure-20260616.tar.gz contiene:

  • 015_pci_q56_mfa_anti_replay.sql — la migración
  • auth-service-diff.patch — backend fix completo
  • q56-replay-test.sh — script reproducible del live test
  • replay-test-transcript.txt — output del live test
  • SOP-005-MFA_EXCEPTION_REQUEST.md — SOP de excepción
  • T1..T8 PNGs

Reproducible end-to-end con auth-service corriendo + Postgres con la migration aplicada.

Documentación Confidencial — Solo para uso interno y auditoría PCI DSS