Face-MFA y recovery del wallet
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é.
El problema que resuelve
Sección titulada «El problema que resuelve»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 nuevoThreat 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).
Cuándo aplica face-MFA
Sección titulada «Cuándo aplica face-MFA»Tres patrones distintos, cada uno con su gating:
Patrón A · Step-up en acción crítica
Sección titulada «Patrón A · Step-up en acción crítica»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 tokenConfigurable 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).
Patrón B · Recovery del wallet
Sección titulada «Patrón B · Recovery del wallet»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 revocadasPatró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 unstep_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”.
Implementación técnica
Sección titulada «Implementación técnica»Modelo de datos (cambios mínimos)
Sección titulada «Modelo de datos (cambios mínimos)»-- 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óEndpoints nuevos
Sección titulada «Endpoints nuevos»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 }Trust policy extendida
Sección titulada «Trust policy extendida»{ "required_loa": "LoA2", "require_face_on": ["passkey_revoke", "transfer_above_1m"], "face_threshold": 0.95, "face_liveness_required": true}Anti-patrones
Sección titulada «Anti-patrones»- 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.
Roadmap de implementación
Sección titulada «Roadmap de implementación»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 operadoresImplicaciones para otros ADRs
Sección titulada «Implicaciones para otros ADRs»- 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_matchya existe en el catálogo y aporta a LoA2. Lo nuevo esface-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.
MVP entregado (2026-05-30)
Sección titulada «MVP entregado (2026-05-30)»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_uriapuntando a S3,rekognition_face_idopcional), revocación (revoked_at,revoke_reason) y RLSdeny_all+ funcionesSECURITY DEFINERpara CRUD desde el service layer. - Migration 0018
face_policy_resolver— funciónSECURITY DEFINERque resuelve eltrust_policy.require_face_ondel 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ónpgvector+ columnaembedding vector(512)+ índice HNSW (cosine) + helpersSECURITY DEFINERface_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.
Provider stack
Sección titulada «Provider stack»- Match biométrico = arcface-service (Python,
/services/arcface/) contrawallet_face_embeddingscon 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.95 ⟶
face_scoreretornado es la cosine similarity de pgvector contra el embedding enrolado en onboarding.
Wire en endpoints destructivos
Sección titulada «Wire en endpoints destructivos»POST /v1/wallet/me/face-step-up/begin— rate-limit 30/min, valida Bearer wallet session, emitechallenge_tokenconliveness_session_id.POST /v1/wallet/me/face-step-up/finish— verifica liveness, hace 1:1 face match, emitestep_up_tokenconface_score+ binding alface_pattern_idmatcheado. Token single-use, TTL 5 min.- Enforcement en
revokeGrantyrevokePasskey: el handler leetrust_policy.require_face_onviaface_policy_resolver. Si el purpose está en la lista, exigeX-Face-Step-Up-Tokenválido además delX-Step-Up-TokenWebAuthn ya existente. Sin face token cuando es requerido → 401.
Feature flags y dev-bypass
Sección titulada «Feature flags y dev-bypass»BMONKEY_FACE_MFA_ENABLED(defaultfalse) — gate global del feature. Tenants nuevos no ven el flow hasta que se prenda.- dev-bypass del liveness por prefijo
dev-enliveness_session_idestá gated por env: solo se acepta cuandoBMONKEY_ENV=devANDBMONKEY_REKOGNITION_ENABLED!=true(DJ-PT-52-03 round 2 fix). En producción el branch es inalcanzable.
Security findings cerrados (round 2)
Sección titulada «Security findings cerrados (round 2)»| ID | Finding | Fix |
|---|---|---|
| DJ-PT-52-01 | begin sin Bearer → 200 | bearerAuth middleware en sub-router de face-step-up |
| DJ-PT-52-02 | challenge sin binding a wallet | challenge_token incluye wallet_id, finish valida mismatch |
| DJ-PT-52-03 | dev-bypass funciona en prod | gate por BMONKEY_ENV=dev AND BMONKEY_REKOGNITION_ENABLED!=true |
| DJ-PT-52-04 | reused step_up_token | single-use enforcement vía step_up_token_consume SQL atómico |
| DJ-PT-52-05 | trust_policy lookup bypassable | resolver SECURITY DEFINER único entry point |
Validación operacional
Sección titulada «Validación operacional»- Build Go verde (
GOWORK=off go build ./...). - Tests unitarios verdes (
go test ./internal/...) excepto el flake pre-existenteTestAppleAppAttestor_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 profilebackendno la setea). La funcionalidad de face-MFA está cubierta por 7/10 specs de13-face-mfa.spec.tsque sí pasan (begin/finish guards, 401s, 404s, cross-wallet binding) más los unit tests.
DEFERRED (fuera de MVP de Fase 2)
Sección titulada «DEFERRED (fuera de MVP de Fase 2)»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-Tokenpero la wallet PWA aún no lo dispara; pendiente sub-PR de frontend para integrarCaptureField mode='selfie'en el modal de step-up. - bseal contract signing (Patrón C) — flow tipo
step_upcon 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.
Decisión
Sección titulada «Decisión»| Aspecto | Decisión |
|---|---|
| Status | Accepted (2026-05-30) |
| Aplicable a | bmonkey-api · wallet PWA · embed |
| Default por tenant | require_face_on: [] (deshabilitado) |
| Threshold mínimo | 0.95 (cosine similarity pgvector contra embedding ArcFace 512-dim) |
| Match provider | arcface-service Python + pgvector HNSW (NO Rekognition Collection) |
| Liveness provider | Rekognition Liveness (depth + reflex challenge) |
| Feature flag | BMONKEY_FACE_MFA_ENABLED (default false) |
| Ventana de recovery | 48h con email + SMS — DEFERRED (no MVP de Fase 2) |
| Re-evaluar | cuando emerjan ataques deepfake que pasen liveness con > 5% success rate |
Fase 3 entregada (2026-05-30)
Sección titulada «Fase 3 entregada (2026-05-30)»F3.2 · Wallet recovery + face PWA — Patrón A parcial + Patrón B completo.
| Componente | Status | Notas |
|---|---|---|
| Patrón B: recovery 48h (Patrón B) | ✅ entregado | migration 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 | ✅ entregado | BMONKEY_NOTIFIER=ses_sns activa el rail; Noop en dev (log only). |
PWA wallet (platform/frontend/user-wallet) | ✅ entregado | 3 estados (request / awaiting-reject / awaiting-finalize) en routes/recovery.tsx + recovery.reject.tsx. |
| Face step-up modal selfie (Patrón A) | ✅ parcial | FaceStepUpModal + CaptureField mode=‘selfie’ en PWA. Endpoint `/v1/wallet/me/face-step-up/begin |
| E2E specs 15 (recovery 13 specs) + 16 (face PWA) | ✅ entregado | Wave 3 qa rondas pasadas. |
DEFERRED a Wave 5:
- BUG-F3.2-02: wire del gate
X-Face-Step-Up-TokenenDELETE /grants/{id}yDELETE /passkeys/{id}(face step-up sólo emite el token pero handler no lo exige). Tracked comoF3.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.