Pular para o conteúdo

Face-MFA y recovery del wallet

Este conteúdo não está disponível em sua língua ainda.

El wallet bjungle usa passkey (WebAuthn) como factor principal de autenticación — un secret bound al hardware que resiste phishing por diseño. Pero el passkey solo prueba “este device aceptó la operación”. Para acciones financieras importantes y para resolver el caso “perdí mi último device”, agregamos la cara del usuario como segundo factor y como mecanismo de recovery.

Este ADR explica cuándo aplica face, cuándo no, y por qué.

Caso 1 · ALGUIEN ROBA EL TELÉFONO DESBLOQUEADO
passkey por sí solo: 🚨 el ladrón entra al wallet, revoca grants,
borra passkeys, queda libre
passkey + face-MFA: ✅ el ladrón no puede pasar la captura facial
→ la operación destructiva no pasa
Caso 2 · USUARIO PIERDE EL ÚLTIMO DEVICE
passkey por sí solo: 💀 wallet inaccesible para siempre
(la clave vivía en Secure Enclave del device)
recovery con face: ✅ usuario hace KYC fresco contra un tenant
que ya lo conoce, el sistema match-ea su cara
contra el patrón biométrico enrolado en el
onboarding original, emite passkey nuevo

Threat model: por qué NUNCA reemplaza al passkey

Sección titulada «Threat model: por qué NUNCA reemplaza al passkey»
PROPIEDAD passkey face password
─────────────────────────────────────────────────────────────────
es secret ✓ ✗ ✓
bound al hardware ✓ ✗ ✗
resiste phishing ✓ ✗ ✗
es único por persona ✓ ✓- ✗
no se puede compartir ✓ ✓- ✗ (lo comparte)
único cuando hay gemelos ✓ ✗ ✓
funciona en cámara mala ✓ ✗ ✓
funciona offline ✓ ✗ ✓
─────────────────────────────────────────────────────────────────

La cara es biométrica pero no secret. Cualquiera con una foto HD, un video, o presencia física puede intentar spoof-ear. El liveness detection (Rekognition Liveness, ArcFace) ayuda pero NO es a prueba de bypass:

  • Foto impresa de alta resolución: ArcFace + liveness ahogan 95-99%.
  • Video grabado: el liveness lo detecta porque exige movimiento + challenge response específico.
  • Deepfake en vivo: estado del arte 2026, hay modelos que pasan algunos liveness providers. Por eso bjungle usa Rekognition Liveness (que combina depth-from-motion + reflex challenge) Y ArcFace match como segundo modelo.
  • Hermanos gemelos: false-accept rate de ArcFace para gemelos está reportado en 5-8%. Mitigación: para LoA3 / acciones financieras altas exigimos passkey + face en lugar de solo face.
  • Persona dormida / drogada / coercida: el passkey con UV (user verification) por Touch ID también la pasa. Es problema social, no técnico. Ningún factor lo resuelve solo.

Por eso bjungle no acepta un mundo donde face reemplace al passkey. Solo:

  • Como segundo factor sobre passkey ya validado.
  • Como bootstrap controlado en recovery, donde es UNA pieza del proceso (también hay sarlaft de no-lavado, tenant que conoce al usuario, audit log, ventana de espera).

Tres patrones distintos, cada uno con su gating:

El operador / end user ya está autenticado con passkey. Va a hacer algo destructivo (revocar grant, rotar API key, transferir > X). El sistema le pide una segunda confirmación con face.

Frontend: usuario click "Eliminar passkey"
→ POST /v1/wallet/me/step-up/begin {purpose: "passkey_revoke"}
→ response: requires_face: true (decisión por trust_policy)
→ Frontend dispara CaptureField en modo selfie
→ POST /v1/wallet/me/step-up/finish {assertion, face_blob}
Backend:
1. Verifica passkey assertion (WebAuthn estándar)
2. Verifica face_blob vs face_pattern_id del usuario
usando Rekognition con threshold 95%
3. Si ambos pasan → emite step_up_token con bind a face_score
4. Acción destructiva consume el token

Configurable por tenant vía trust_policy.require_face_on:

{
"require_face_on": [
"passkey_revoke",
"grant_revoke_high_value",
"transfer_above_1m_cop"
]
}

Sin trust_policy explícita, default = solo passkey (no face).

Usuario perdió todos sus devices. NO puede pasar el login con passkey. Flow:

1. Usuario entra a wallet.bjungle.com/login en un device nuevo.
2. Click "Perdí mis dispositivos" → recovery flow.
3. wallet PWA muestra: "Te vamos a pedir hacer KYC otra vez con un
tenant que ya te conoce. Esto puede tardar unos días.
⚠ Por seguridad, todas tus passkeys actuales serán REVOCADAS y
tendrás que enrolar una nueva en este device."
4. Usuario elige el tenant (lista de los que tienen grant activo).
5. Sistema redirige a embed/<token> del tenant con flow tipo
onboarding modificado (sin OCR, sin sarlaft — esos datos ya
existen). Solo captura selfie + liveness.
6. Backend hace face_match contra TODOS los patrones enrolados de
este usuario. Si NINGUNO matchea con confidence >95% → reject.
7. Si matchea:
- Mark recovery request as "biometric verified"
- Trigger "cool-down" notification al email/SMS registrado:
"Alguien está intentando recuperar tu wallet. Si NO sos tú,
respondé a este email en las próximas 48h."
8. Después de 48h sin objeción del usuario original:
- Revoke TODAS las passkeys del wallet
- Emit nuevo wallet_session_token
- Frontend pide enrolar passkey nuevo
9. Audit log entry permanente:
- quién hizo el recovery (tenant que vehículo el KYC)
- face_score
- timestamp
- passkeys revocadas

Patrón C · Authoritative face login (LoA3 only)

Sección titulada «Patrón C · Authoritative face login (LoA3 only)»

En aplicaciones de banca tradicional, algunos casos requieren probar “sos vos, en este momento, en serio” sin importar el device. Ejemplo: firma de un contrato de crédito.

flow tipo step_up:
- face_match (con liveness)
- sms_otp
Cuando el tenant invoca:
POST /v1/flows/sessions {type: "step_up", code: "loan_signature"}
El usuario entra al embed, hace face + sms, bjungle emite un
step_up_token de LoA3 que el tenant adjunta a la firma del contrato.

Este patrón NO usa passkey porque la lógica es “tenant quiere prueba de presencia física del usuario, independiente de que tenga su device activo”. Útil para:

  • Firma de contratos legales en bseal con peso jurídico.
  • Reautorización de operaciones de muy alto monto.
  • Auditoría regulatoria que exige “biometric reaffirmation”.
-- Existente desde T15:
-- wallet_passkeys (passkey por device)
-- Nueva tabla:
CREATE TABLE bmonkey.face_patterns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wallet_id UUID NOT NULL REFERENCES bmonkey.wallets(id),
rekognition_face_id TEXT, -- ID en Rekognition collection
arcface_template_uri TEXT, -- ref S3 al vector ArcFace
enrolled_by_tenant_id UUID,
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
revoke_reason TEXT
);
-- Extender step_up_tokens (existente desde T30):
ALTER TABLE bmonkey.step_up_tokens
ADD COLUMN face_score NUMERIC, -- 0.0-1.0, null si solo passkey
ADD COLUMN face_pattern_id UUID; -- cuál patrón matchó
POST /v1/wallet/me/face-step-up/begin
→ genera challenge para la captura facial (anti-replay)
→ response: { challenge_token, livness_session_id }
POST /v1/wallet/me/face-step-up/finish
→ body: { challenge_token, selfie_blob (base64), liveness_session_id }
→ backend:
- verifica liveness via Rekognition Liveness
- face_match vs patterns del wallet (ArcFace + Rekognition)
- score combinado debe pasar threshold (default 0.95)
→ response: { step_up_token, expires_at, face_score }
{
"required_loa": "LoA2",
"require_face_on": ["passkey_revoke", "transfer_above_1m"],
"face_threshold": 0.95,
"face_liveness_required": true
}
  • Tenant que requiere face_match en cada login. UX malo, costo S3
    • Rekognition $$ por sesión, y NO suma seguridad sobre passkey bien implementado. Caso correcto: face solo en acciones específicas (require_face_on lista).
  • Recovery sin window de 48h. Roba la sesión a cualquiera con foto + un tenant cooperante. La ventana es no-negociable.
  • “Solo face” en LoA3. Reemplazar passkey por face baja el nivel real de aseguramiento aunque el LoA nominal sea 3. Combinar.
  • Threshold demasiado bajo (e.g. 0.80) para “facilitar al cliente”. False-accept salta del 0.5% al 5%. Mantener default 0.95.
Fase 1 (~1 semana)
- Tabla face_patterns
- Endpoint face-step-up/{begin,finish}
- Wire en revoke endpoints (opt-in vía trust_policy)
- Frontend: CaptureField mode='selfie' en step-up modal
Fase 2 (~1 semana)
- Recovery flow (48h cool-down, email aviso)
- Backup notification path (email Y SMS si están enrolados)
Fase 3 (~3 días)
- Step-up estilo LoA3 para bseal contract signing
- Trust policy: require_face_on opciones
- Métricas: false-accept rate por tenant en panel de operadores
  • ADR-001 audiencias: face-MFA aplica a end user y opcionalmente a operadores (si el tenant lo activa). Máquina nunca.
  • ADR-006 flow composition: el step face_match ya existe en el catálogo y aporta a LoA2. Lo nuevo es face-step-up (out-of-flow, on-demand, no contribuye a LoA del VC base sino que produce step_up_token).
  • Wallet Modelo C: las claves criptográficas siguen bound al device. La cara nunca firma — solo gatea el uso de la passkey ya enrolada.

Lo que está cerrado en este sprint corresponde a Patrón A (step-up out-of-flow) wired en los endpoints destructivos del wallet. El resto del roadmap está enumerado en la sección DEFERRED.

  • Migration 0016 face_patterns — tabla global por persona con metadata (enrolled_by_tenant_id, arcface_template_uri apuntando a S3, rekognition_face_id opcional), revocación (revoked_at, revoke_reason) y RLS deny_all + funciones SECURITY DEFINER para CRUD desde el service layer.
  • Migration 0018 face_policy_resolver — función SECURITY DEFINER que resuelve el trust_policy.require_face_on del flow publicado más reciente del tenant (type='onboarding') sin requerir sesión con tenant context. Es el único path soportado para que el wallet (sesión sin tenant) lea política de face por tenant.
  • Migration 0019 wallet_face_embeddings — extensión pgvector + columna embedding vector(512) + índice HNSW (cosine) + helpers SECURITY DEFINER face_embedding_store, face_embedding_search (con threshold), face_embedding_revoke. Modelo idéntico al validado en cashpaya: el embedding ArcFace 512-dim vive directamente en Postgres, las búsquedas son cosine similarity con HNSW.
  • Match biométrico = arcface-service (Python, /services/arcface/) contra wallet_face_embeddings con pgvector. Rekognition Collection fue descartado en round 3 por costo (~USD 0.001 por face indexed + USD 0.001 por SearchFacesByImage) y portabilidad (dependencia AWS hard-coded). El patrón cashpaya estaba ya validado en producción.
  • Liveness = Rekognition Liveness (producto AWS distinto del Collection — solo combina depth-from-motion + reflex challenge, no almacena rostros). Sigue como provider porque la alternativa self-hosted suma ~3 semanas de research y este MVP no lo justifica.
  • Threshold combinado 0.95face_score retornado es la cosine similarity de pgvector contra el embedding enrolado en onboarding.
  • POST /v1/wallet/me/face-step-up/begin — rate-limit 30/min, valida Bearer wallet session, emite challenge_token con liveness_session_id.
  • POST /v1/wallet/me/face-step-up/finish — verifica liveness, hace 1:1 face match, emite step_up_token con face_score + binding al face_pattern_id matcheado. Token single-use, TTL 5 min.
  • Enforcement en revokeGrant y revokePasskey: el handler lee trust_policy.require_face_on via face_policy_resolver. Si el purpose está en la lista, exige X-Face-Step-Up-Token válido además del X-Step-Up-Token WebAuthn ya existente. Sin face token cuando es requerido → 401.
  • BMONKEY_FACE_MFA_ENABLED (default false) — gate global del feature. Tenants nuevos no ven el flow hasta que se prenda.
  • dev-bypass del liveness por prefijo dev- en liveness_session_id está gated por env: solo se acepta cuando BMONKEY_ENV=dev AND BMONKEY_REKOGNITION_ENABLED!=true (DJ-PT-52-03 round 2 fix). En producción el branch es inalcanzable.
IDFindingFix
DJ-PT-52-01begin sin Bearer → 200bearerAuth middleware en sub-router de face-step-up
DJ-PT-52-02challenge sin binding a walletchallenge_token incluye wallet_id, finish valida mismatch
DJ-PT-52-03dev-bypass funciona en prodgate por BMONKEY_ENV=dev AND BMONKEY_REKOGNITION_ENABLED!=true
DJ-PT-52-04reused step_up_tokensingle-use enforcement vía step_up_token_consume SQL atómico
DJ-PT-52-05trust_policy lookup bypassableresolver SECURITY DEFINER único entry point
  • Build Go verde (GOWORK=off go build ./...).
  • Tests unitarios verdes (go test ./internal/...) excepto el flake pre-existente TestAppleAppAttestor_RootCertParses (no del sprint).
  • E2E suite: 51/59 pass + 4 skip + 4 fail — los 4 fail son del dev-bypass path en spec 13 cuando el compose se levanta sin BMONKEY_ENV=dev (default profile backend no la setea). La funcionalidad de face-MFA está cubierta por 7/10 specs de 13-face-mfa.spec.ts que sí pasan (begin/finish guards, 401s, 404s, cross-wallet binding) más los unit tests.

Items del ADR que NO entran en el sprint actual. Cada uno se abrirá como sub-PR dedicado.

  • Recovery flow (Patrón B) — cool-down 48h, email/SMS notification al usuario original, revocación masiva de passkeys. Pendiente diseño de la UI del wallet PWA y del flow de soporte.
  • Face-MFA en login PWA (Patrón A end-user side) — el backend acepta el X-Face-Step-Up-Token pero la wallet PWA aún no lo dispara; pendiente sub-PR de frontend para integrar CaptureField mode='selfie' en el modal de step-up.
  • bseal contract signing (Patrón C) — flow tipo step_up con face + sms_otp emitiendo step_up_token LoA3 para firma de contratos. Pendiente sprint dedicado (depende de bseal habilitar PAdES o similar).
  • Métricas de false-accept rate en panel de operadores — telemetría por tenant del score distribution + alarmas cuando el rate sube por encima del threshold esperado. Pendiente sprint de observabilidad.
AspectoDecisión
StatusAccepted (2026-05-30)
Aplicable abmonkey-api · wallet PWA · embed
Default por tenantrequire_face_on: [] (deshabilitado)
Threshold mínimo0.95 (cosine similarity pgvector contra embedding ArcFace 512-dim)
Match providerarcface-service Python + pgvector HNSW (NO Rekognition Collection)
Liveness providerRekognition Liveness (depth + reflex challenge)
Feature flagBMONKEY_FACE_MFA_ENABLED (default false)
Ventana de recovery48h con email + SMS — DEFERRED (no MVP de Fase 2)
Re-evaluarcuando emerjan ataques deepfake que pasen liveness con > 5% success rate

F3.2 · Wallet recovery + face PWA — Patrón A parcial + Patrón B completo.

ComponenteStatusNotas
Patrón B: recovery 48h (Patrón B)✅ entregadomigration 0022_wallet_recovery_requests, atomic reject (DJ-PT-F3.2-04) en 0023_recovery_atomic_reject. Endpoints POST /v1/wallet/recovery/begin, GET /{id}/status, POST /reject?token=, POST /{id}/finalize (internal). Cron worker recovery_finalizer corre KYC re-verify al expirar la ventana.
Notifier SES+SNS recovery✅ entregadoBMONKEY_NOTIFIER=ses_sns activa el rail; Noop en dev (log only).
PWA wallet (platform/frontend/user-wallet)✅ entregado3 estados (request / awaiting-reject / awaiting-finalize) en routes/recovery.tsx + recovery.reject.tsx.
Face step-up modal selfie (Patrón A)✅ parcialFaceStepUpModal + CaptureField mode=‘selfie’ en PWA. Endpoint `/v1/wallet/me/face-step-up/begin
E2E specs 15 (recovery 13 specs) + 16 (face PWA)✅ entregadoWave 3 qa rondas pasadas.

DEFERRED a Wave 5:

  • BUG-F3.2-02: wire del gate X-Face-Step-Up-Token en DELETE /grants/{id} y DELETE /passkeys/{id} (face step-up sólo emite el token pero handler no lo exige). Tracked como F3.W5 · wire X-Face-Step-Up-Token gate en DELETE handlers (BUG-F3.2-02).
  • Patrón C bseal: face + sms_otp emitiendo step_up_token LoA3 para firma de contratos. Pendiente bseal PAdES.
  • Métricas false-accept rate en panel operadores — pendiente sprint observabilidad.