Ir al contenido

ADR-011 · Multi-firma bseal para documentos bipartitos

CampoValor
StatusAccepted (jguerrero 2026-06-05)
Date2026-06-05
Authorstech-lead agent + jguerrero
Supersedes
Superseded by

bseal ya tiene un modelo de circuits multi-firmante implementado en código pero no usado en producción todavía. Estado al 2026-06-05:

  • bseal/backend/go-bseal-api/internal/domain/circuit.go define:
    • Circuit (workflow versionado con SigningMethod ∈ pades_tenant)
    • CircuitSigner (un rol por circuit, con RoutingOrder + AuthRequirements)
    • Envelope (instancia de un circuit con N signers concretos)
    • EnvelopeSigner con states pending|authenticated|signed|declined
    • AuditEvent por evento con Source, EventType, Actor, etc.
  • Routing ∈ parallel.
  • internal/service/circuit_service.go + envelope_service.go implementan el ciclo draft → published → deprecated y la máquina de estados por signer.
  • internal/http/circuits_handler.go + envelopes_handler.go exponen los endpoints.

El gap analysis identifica este flow como **gap #47 “multi-firma (cliente

  • tenant rep), backlog L”** pero la decisión jguerrero #11 del 2026-06-05 lo movió a P0 de Wave A:

“Multi-firma bipartita (cliente + cashpaya) → P0 para onboarding ToS → mover a Wave A (no esperar D). Doc legal cashpaya requiere firma cliente+empresa.”

La pregunta arquitectónica que este ADR cierra: cómo se compone una firma bipartita — quién firma con qué llave, en qué orden, qué queda en el certificado de evidencia, y cuál es la diferencia entre un doc “personal” (cliente firma como sujeto natural) y uno “corporativo” (tenant firma como empresa).

Cashpaya necesita esto ya para el template “Términos Cashpaya v1” (Wave A7 + A7b del backlog).

1. Activar circuits multi-firmante como path canónico para docs bipartitos

Sección titulada «1. Activar circuits multi-firmante como path canónico para docs bipartitos»

bseal ya soporta circuits con N signers — esa funcionalidad pasa a ser el path para docs firmados por más de una parte. El path legacy POST /v1/signatures {template_id, subject_ref, merge_data} (firma single-signer simple) se mantiene solo para docs unilaterales (certificado de fin de servicio, recibo, comprobante).

Tipo docPath canónico
Doc unilateral firmado por tenant (corporativo simple)POST /v1/signatures (single, KMS tenant)
Doc unilateral firmado por subject (autorización personal)POST /v1/signatures (single, wallet key del subject) — ver §3
Doc bipartito cliente + tenant (ToS, contrato bilateral)POST /v1/envelopes con circuit.signers = 2
Doc N-partito (3+ firmantes secuencial)POST /v1/envelopes con circuit.signers = N

Para docs bipartitos cliente + tenant_rep, el routing default es serial con el subject (cliente) firmando primero, tenant después.

Justificación:

  • Legal CO: el subject firma su consentimiento como acto autónomo; el tenant rep firma confirmando la oferta. Si el tenant firma primero y el subject nunca firma, el doc queda en limbo legal. Si el subject firma primero y el tenant declina, el subject sabe que su firma fue registrada pero la oferta no se cerró — más claro.
  • UX: el subject está en el flow onboarding, ahí mismo firma. El tenant rep firma asincrónicamente (cron / dashboard) — no bloquea al user.
  • Evidencia: timestamp del subject < timestamp del tenant rep. Eso es lo que un juez espera ver en un contrato.

Si en el futuro un cliente requiere parallel (sucede en M&A docs), está soportado por circuit.routing = "parallel". No es el default.

Para cada EnvelopeSigner, la clave usada se infiere del rol:

  • Wallet key del subject (Modelo C wallet, ADR wallet-modelo-c).
  • Decisión jguerrero #5: “Wallet key para personales / KMS-tenant para corporativos”. Personal = el subject firma como persona natural.
  • Implementación: el handler de “advance signing” del envelope, cuando toca el turno del subject:
    1. Verifica step_up_token reciente (face_match passkey, 5min TTL — ver ADR-002 face-mfa-recovery).
    2. Si no hay token válido → 401 + prompt frontend face_match.
    3. Si hay token → ejecuta firma con wallet key (signing service bmonkey expone POST /v1/wallet/me/sign-document {digest}).
    4. bseal-worker recibe la firma + cert chain del wallet + persiste en envelope_signers.signature_blob.
  • KMS-tenant key (RSA o EC), referenciada por kms_arn en signing_certificates (table existente del módulo).
  • Decisión jguerrero #5 + #10 (ADR-013):
    • Por default, los tenants firman con alias/bjungle-corporate-signer-prod (cert global bjungle, autoridad firmante centralizada).
    • Tenants enterprise pueden opt-in a su propia cert (Wave A7c + ADR-013).
  • Implementación: bseal-worker ya hace KMS sign sobre el digest del PDF. La extensión es elegir el kms_arn correcto según el rol del signer + cert asociado al tenant.
RolClaveModoCert subject
subject (cliente persona natural)Wallet key (KMS bjungle, scoped to wallet_id)RSASSA-PKCS1-v1_5-SHA256 over digestCN = wallet:<wallet_id> (cert del wallet, ver ADR wallet-modelo-c)
tenant_rep (rep corporativo default)alias/bjungle-corporate-signer-prod (KMS bjungle global)RSASSA-PKCS1-v1_5-SHA256 over digestCN = Bjungle SAS - Authoritative Signer
tenant_rep (rep corporativo, tenant con cert propio)KMS-tenant ARN específicoRSASSA-PKCS1-v1_5-SHA256 over digestCN = <Razón social del tenant>

CircuitSigner.AuthRequirements ya tiene los campos canónicos (circuit.go:95-101):

type AuthRequirements struct {
EmailOTP bool // OTP por email antes de firmar
SMSOTP bool // OTP por SMS antes de firmar
AccessCode bool // PIN único por envelope
Identity bool // doc + biometrics via bmonkey flow
Consent bool // ESIGN/UETA intent-to-sign explícito
}

Recomendaciones por tipo de signer:

RolEmailOTPSMSOTPAccessCodeIdentityConsent
Subject onboarding (Wave A)❌ (ya hizo flow)✅ (el flow lo cumplió)
Subject post-onboarding (firma operación)⚠️ opcional✅ (step_up reciente)
Tenant rep (operator humano)
Tenant rep (firma automática batch)✅ (acto delegado documentado)

Cada firma deja entradas en envelope_audit_events:

AuditAuthenticated → cuando el signer pasó AuthRequirements
AuditConsented → cuando hizo intent-to-sign click
AuditSigned → cuando se cerró la firma (KMS Sign retornó)

Con metadata mínima:

  • signer_id (envelope_signer UUID)
  • signer_role (subject | tenant_rep | otro)
  • signer_identity (wallet:<wallet_id> | kms:<arn> | user:<operator_id>)
  • ip (origen request)
  • user_agent
  • timestamp (server-side, no cliente)
  • step_up_token_id (si aplicó face_match para autorizar)
  • kms_sign_request_id (response ID de AWS KMS, para reconciliación)

Esto cumple ESIGN Act + Ley CO 527/1999 (firma electrónica + mensajes de datos).

El “certificado de firma” final (PDF de evidencia que bseal-worker genera) incluye:

  • Hash SHA-256 del PDF base + merge_data renderizado
  • Por cada signer:
    • Nombre + email + role
    • Timestamp de firma
    • KMS ARN usado (o wallet_id)
    • Cert subject DN (CN del cert X.509)
    • Signature blob (base64)
  • Hash chain: hash del PDF original + concatenación ordenada de firmas → hash final (Merkle-light)
  • Verificación externa: aws kms verify con el ARN + digest + signature

PAdES embedded (firma visualmente embebida en el PDF) queda en backlog post-MVP — gap #48 del análisis. MVP firma sobre el digest.

Wave A7:

  • cashpaya_tos_v1 — Términos de servicio cashpaya
    • Circuit serial, 2 signers
    • Signer 1 (role=subject): wallet key del cliente, Identity + Consent
    • Signer 2 (role=tenant_rep): KMS bjungle (o cert cashpaya en Wave A7c), EmailOTP + Consent
    • Routing serial: cliente primero, tenant después

Templates futuros (Wave B/C/D):

  • cashpaya_authorization_tx (autorización de transacción individual, single signer = subject)
  • cashpaya_account_closing (cierre de cuenta, bipartito)
  • Cumple legal CO para ToS: ahora cashpaya tiene un doc firmado por ambas partes con audit trail externo verificable. Cierre del riesgo legal #1 del producto.
  • Reuso de circuits ya implementado: ~80% del código ya existe. Wave A7b (handler /co-sign) es <1 día de trabajo, no semana.
  • Frontera limpia personal vs corporativo: wallet key para acto personal, KMS para acto corporativo. Filosóficamente claro.
  • Verificable sin acceso a DB bjungle: cualquier verificador con el PDF + KMS ARN puede correr aws kms verify y confirmar la firma. Sin consultar APIs nuestras.
  • Extensible a N firmas: el mismo circuit soporta M&A docs con 5 firmantes secuenciales o paralelos sin código nuevo, solo seed.
  • Wallet key debe poder firmar PDFs (gap #26): el subject path requiere extender bmonkey wallet_service con un endpoint sign-document. ~2-3 días de trabajo. Sin esto, el subject solo puede firmar via KMS tenant — funciona pero rompe la decisión jguerrero #5 (“wallet key para personales”).
  • Step-up obligatorio en cada firma personal: el subject hace face_match cada vez que firma (o reusa token si <5 min). Para docs largos con varios sub-firmados puede ser tedioso. Mitigación: agrupar varios sign actions bajo un solo step_up_token de purpose sign_session.
  • No soporta firma offline / sin internet: el wallet del cliente está en su device con Secure Enclave pero la firma require POST al backend bmonkey (para KMS access). Mitigación futura: que la wallet firme localmente con secret share (Modelo C “client share”) y bjungle contrasign con su share. Backlog, no MVP.
  • Tenant rep firma asincrónicamente: si tarda días, el envelope queda in_progress y el subject ya firmó pero el doc no es ejecutable. Mitigación: TTL configurable por envelope + email recordatorio al tenant operator + auto-decline tras N días.
  • PAdES embedded sigue en backlog: bseal MVP firma sobre digest, no embebe la firma en el PDF. La firma se distribuye junto al certificado de evidencia. Esto cumple legal CO 527/1999 pero no es PAdES B-LT. Reabrir cuando aparezca un cliente que lo exija (gap #48).
  • Multi-tenant del tenant rep: si una empresa cliente tiene 3 operators que pueden firmar como tenant_rep, ¿cuál firma? El que esté logueado en el dashboard cuando le toca al envelope. Audit registra operator_id específico además de “tenant_rep” como rol.
AlternativaPor qué se descartó
Una sola firma del subject (sin tenant rep)No cumple legal CO para ToS — el contrato bilateral requiere ambas firmas. Si el tenant no firma, el doc no es ejecutable contra el tenant.
Firma del tenant solo (sin firma subject)No vincula al subject. Si el subject niega haber aceptado, no hay evidencia firmada por él. Click-wrap no es suficiente para regulación financiera CO.
Firma simultánea sin orden (parallel)Posible pero confuso UX-wise para ToS — el subject firma sin saber si la oferta sigue vigente. Para ToS estándar, serial cliente→tenant es industria standard. Parallel se reserva para M&A.
Click-wrap “Acepto los términos” sin firma criptográficaSuficiente para apps no reguladas. Pero cashpaya está regulada SFC y firma electrónica con audit trail verificable externamente es mucho más defensible. El extra costo es bajo (~50 Açaí por firma) y la robustez legal vale la pena.
DocuSign / HelloSign externoCosto recurrente (USD 25-60/user/mes), data leakage (PII del subject sale a 3rd party), latencia adicional, no integrado con wallet SSI. bseal interno es mejor en todas las dimensiones para nuestro use case.
Firma single-signer concatenada (cada parte firma su versión del PDF)Cada firma sobre un PDF distinto rompe la cadena hash. Verificación externa imposible — dos PDFs distintos no son el mismo doc.

Files a tocar (Wave A7b + dependencias):

  • bseal/backend/go-bseal-api/internal/http/envelopes_handler.go — agregar endpoint POST /v1/envelopes/{id}/co-sign que advance el envelope al siguiente signer (puede ser que el handler /sign existente ya cubra; verificar)
  • bseal/backend/go-bseal-api/internal/service/envelope_service.go — branching del KMS ARN según signer.role:
    • Si role=subject → llamar bmonkey POST /v1/wallet/me/sign-document
    • Si role=tenant_rep → KMS sign con cert.kms_arn
  • bseal/backend/go-bseal-api/internal/repo/envelope_repo.go — asegurar que el query de “next signer in routing” respeta RoutingOrder y skipea declined
  • bmonkey/backend/go-bmonkey-api/internal/http/wallet_handler.go — agregar POST /v1/wallet/me/sign-document (gap #26):
    • Requiere Bearer session token del wallet + step_up_token válido
    • Input: {digest_b64, purpose}
    • Output: {signature_b64, cert_chain_b64, kms_arn, wallet_id}
  • bmonkey/backend/go-bmonkey-api/internal/service/wallet_service.go — extender con SignDocument(ctx, walletID, digest, purpose). Reusa el KMS access del vc_issuer.

Migrations:

  • bseal 0008_envelope_signer_metadata.up.sql (opcional) — agregar columnas step_up_token_id text NULL + kms_sign_request_id text NULL
    • cert_subject_dn text NULL en envelope_signers para mejor audit.

Patrones a respetar:

  • assertDraft del envelope antes de mutar: igual que las validations bhawk, los envelopes published son inmutables. Solo sent → in_progress → completed/declined/voided/expired.
  • Outbox events: bseal.envelope.signed + bseal.envelope.completed por NATS. Cashpaya o el orchestrator puede consumir esto.
  • No mostrar al subject que existe el tenant_rep aún por firmar: UX cashpaya recibe el “doc firmado pendiente contraparte” pero al subject le dice “firmado, en proceso”. Reduce confusión.

Tests obligatorios:

  • Happy path bipartito: crear circuit cashpaya_tos_v1, send envelope a subject existente, subject firma con wallet key, tenant rep firma con KMS, doc final tiene 2 firmas verificables, envelope.status = completed.
  • Subject declina: subject hace POST /sign/decline → envelope status=declined, tenant rep no se llama. Audit registra razón.
  • Tenant rep declina post subject: subject firmó OK, tenant rep declina → envelope status=declined, audit registra. Subject notificado.
  • Routing serial enforced: el handler debe rechazar si el tenant_rep intenta firmar antes que el subject (ErrSignerNotTurn, ya existe en domain/circuit.go:309).
  • Step-up missing en subject sign: rechazar 401 si no hay step_up_token válido (purpose=sign_envelope, target_ref=envelope_id).
  • KMS sign rate limit: AWS KMS throttle es ~5000 req/s soft limit. Test que confirme retry con backoff si throttle.

Riesgos a vigilar:

  • Operator humano del tenant tarda días en firmar: TTL del envelope
    • reminder email + auto-decline. Configurable por circuit.
  • Subject firma, luego pierde acceso a su wallet (passkey perdida): la firma ya está, no se invalida. Pero si necesita firmar otro doc y aún no completó recovery 48h, queda bloqueado. Patrón normal del wallet, no específico de este ADR.
  • Cert del tenant_rep expirado: validar ValidTo > now() en cada firma. Si expirado → bloquear + alert al tenant para renovar.
  • Ley 527 de 1999 (Colombia) — firma electrónica: una firma electrónica avanzada requiere (a) único al firmante, (b) identifica al firmante, (c) bajo control exclusivo, (d) detecta cualquier alteración posterior. Nuestro modelo cumple:
    • (a) wallet key única por subject / KMS arn único por tenant
    • (b) audit trail con signer_identity + step_up_token
    • (c) wallet en Secure Enclave + KMS bajo IAM least-privilege
    • (d) hash SHA-256 del PDF + signature externamente verificable
  • eIDAS (UE) compatibilidad: no aplica directamente para CO pero el modelo es compatible con QES (Qualified Electronic Signature) si en el futuro adquirimos un cert calificado de CA EU.
  • ESIGN Act / UETA (US): intent-to-sign explícito via AuthRequirements.Consent. Audit registra el momento del consent.
  • ADR-013 dependency: el cert X.509 que firma como tenant_rep default (bjungle global) requiere CA certificadora colombiana (CertCámara o ANDES SCD). Sin ADR-013 cerrado, este ADR firma con self-signed solo en sandbox. Bloqueante para Wave A producción.
  • UIAF reporting: el certificado de firma queda en signed_documents como evidencia auditable. cashpaya lo consume para armar el ROS cuando aplique.

Resoluciones jguerrero 2026-06-05 — todas cerradas para Accepted.

#PreguntaResolución
1Seguridad de POST /v1/wallet/me/sign-documentPurpose + step_up bind. Payload requiere purpose + bind a step_up_token.purpose=sign_envelope con target_ref=envelope_id. Sin attestation extra de app en MVP — el step_up consumido + scope de session token son suficientes. security agent valida implementación en Wave A.
2Tenant rep humano vs automáticoSiempre humano en MVP. Operator del tenant firma manualmente desde dashboard cashpaya. No auto-firma — la defensa legal de “el rep aprobó explícitamente” es más clara y la latencia (horas/días) es aceptable porque el subject ya tiene el doc firmado por su parte y eso cierra su consent. Auto-firma queda para reabrir post-launch si la fricción operacional lo justifica.
3Step-up reusable en sesiónpurpose=sign_session cubre N firmas. Un solo face_match con TTL 5 min cubre todos los docs de la sesión (ToS + Data Policy + Authorization). Cada firma individual deja su audit propio (step_up_token_id + signer_id), pero la identidad biométrica se establece una vez. UX mucho mejor para onboarding.
4Asignación tenant_rep concretoRuntime round-robin. Circuit declara role=tenant_rep sin operator concreto; envelope service asigna en runtime al operator activo del tenant con menor carga. Si el operator asignado declina, reasigna automático. Tenant puede override con claim manual desde dashboard (sobrescribe asignación).
5PAdES embedded MVP vs backlogBacklog Wave E. MVP firma sobre digest + certificado de evidencia externamente verificable con aws kms verify. Cumple Ley 527/1999 hoy. PAdES B-LT entra en Wave E (post-cashpaya launch) cuando ADR-013 cierre y tengamos cert calificado de CertCámara/ANDES SCD. Gap #48 queda abierto en el inventario.