ADR-011 · Multi-firma bseal para documentos bipartitos
| Campo | Valor |
|---|---|
| Status | Accepted (jguerrero 2026-06-05) |
| Date | 2026-06-05 |
| Authors | tech-lead agent + jguerrero |
| Supersedes | — |
| Superseded by | — |
Context
Sección titulada «Context»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.godefine:Circuit(workflow versionado conSigningMethod∈ pades_tenant)CircuitSigner(un rol por circuit, conRoutingOrder+AuthRequirements)Envelope(instancia de un circuit con N signers concretos)EnvelopeSignercon statespending|authenticated|signed|declinedAuditEventpor evento conSource,EventType,Actor, etc.
Routing∈ parallel.internal/service/circuit_service.go+envelope_service.goimplementan el ciclo draft → published → deprecated y la máquina de estados por signer.internal/http/circuits_handler.go+envelopes_handler.goexponen 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).
Decision
Sección titulada «Decision»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 doc | Path 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 |
2. Orden de firma: serial, cliente primero
Sección titulada «2. Orden de firma: serial, cliente primero»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.
3. Quién firma con qué llave
Sección titulada «3. Quién firma con qué llave»Para cada EnvelopeSigner, la clave usada se infiere del rol:
3.a · Subject (cliente, personal)
Sección titulada «3.a · Subject (cliente, personal)»- 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:
- Verifica
step_up_tokenreciente (face_match passkey, 5min TTL — ver ADR-002 face-mfa-recovery). - Si no hay token válido → 401 + prompt frontend face_match.
- Si hay token → ejecuta firma con wallet key (signing service
bmonkey expone
POST /v1/wallet/me/sign-document {digest}). - bseal-worker recibe la firma + cert chain del wallet + persiste en
envelope_signers.signature_blob.
- Verifica
3.b · Tenant rep (corporativo)
Sección titulada «3.b · Tenant rep (corporativo)»- KMS-tenant key (RSA o EC), referenciada por
kms_arnensigning_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).
- Por default, los tenants firman con
- Implementación: bseal-worker ya hace KMS sign sobre el digest del
PDF. La extensión es elegir el
kms_arncorrecto según el rol del signer + cert asociado al tenant.
3.c · Tabla resumen de llaves por rol
Sección titulada «3.c · Tabla resumen de llaves por rol»| Rol | Clave | Modo | Cert subject |
|---|---|---|---|
subject (cliente persona natural) | Wallet key (KMS bjungle, scoped to wallet_id) | RSASSA-PKCS1-v1_5-SHA256 over digest | CN = 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 digest | CN = Bjungle SAS - Authoritative Signer |
tenant_rep (rep corporativo, tenant con cert propio) | KMS-tenant ARN específico | RSASSA-PKCS1-v1_5-SHA256 over digest | CN = <Razón social del tenant> |
4. Auth requirements por signer
Sección titulada «4. Auth requirements por signer»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:
| Rol | EmailOTP | SMSOTP | AccessCode | Identity | Consent |
|---|---|---|---|---|---|
| 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) |
5. Audit trail por firma
Sección titulada «5. Audit trail por firma»Cada firma deja entradas en envelope_audit_events:
AuditAuthenticated → cuando el signer pasó AuthRequirementsAuditConsented → cuando hizo intent-to-sign clickAuditSigned → 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_agenttimestamp(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).
6. Certificado de firma — qué embebe
Sección titulada «6. Certificado de firma — qué embebe»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 verifycon 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.
7. Doc tipos pre-seeded para cashpaya
Sección titulada «7. Doc tipos pre-seeded para cashpaya»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
- Circuit
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)
Consequences
Sección titulada «Consequences»Positivas
Sección titulada «Positivas»- 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<1dí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 verifyy 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.
Negativas / trade-offs
Sección titulada «Negativas / trade-offs»- 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 purposesign_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_progressy 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.
Neutrales
Sección titulada «Neutrales»- 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 registraoperator_idespecífico además de “tenant_rep” como rol.
Alternatives considered
Sección titulada «Alternatives considered»| Alternativa | Por 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áfica | Suficiente 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 externo | Costo 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. |
Implementation notes
Sección titulada «Implementation notes»Files a tocar (Wave A7b + dependencias):
bseal/backend/go-bseal-api/internal/http/envelopes_handler.go— agregar endpointPOST /v1/envelopes/{id}/co-signque advance el envelope al siguiente signer (puede ser que el handler/signexistente ya cubra; verificar)bseal/backend/go-bseal-api/internal/service/envelope_service.go— branching del KMS ARN segúnsigner.role:- Si
role=subject→ llamar bmonkeyPOST /v1/wallet/me/sign-document - Si
role=tenant_rep→ KMS sign concert.kms_arn
- Si
bseal/backend/go-bseal-api/internal/repo/envelope_repo.go— asegurar que el query de “next signer in routing” respeta RoutingOrder y skipea declinedbmonkey/backend/go-bmonkey-api/internal/http/wallet_handler.go— agregarPOST /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 conSignDocument(ctx, walletID, digest, purpose). Reusa el KMS access delvc_issuer.
Migrations:
- bseal
0008_envelope_signer_metadata.up.sql(opcional) — agregar columnasstep_up_token_id text NULL+kms_sign_request_id text NULLcert_subject_dn text NULLenenvelope_signerspara 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.completedpor NATS. Cashpaya o el orchestrator puede consumir esto. - No mostrar al subject que existe el
tenant_repaú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→ envelopestatus=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_repintenta firmar antes que elsubject(ErrSignerNotTurn, ya existe endomain/circuit.go:309). - Step-up missing en subject sign: rechazar 401 si no hay
step_up_tokenvá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.
Compliance / regulatory considerations
Sección titulada «Compliance / regulatory considerations»- 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_repdefault (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_documentscomo evidencia auditable. cashpaya lo consume para armar el ROS cuando aplique.
Open questions
Sección titulada «Open questions»Resoluciones jguerrero 2026-06-05 — todas cerradas para Accepted.
| # | Pregunta | Resolución |
|---|---|---|
| 1 | Seguridad de POST /v1/wallet/me/sign-document | Purpose + 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. |
| 2 | Tenant rep humano vs automático | Siempre 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. |
| 3 | Step-up reusable en sesión | purpose=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. |
| 4 | Asignación tenant_rep concreto | Runtime 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). |
| 5 | PAdES embedded MVP vs backlog | Backlog 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. |
Cross-references
Sección titulada «Cross-references»- Gap analysis sección 1.4 (firma electrónica) + decisión #11:
integracion-cashpaya-gap-analysis.md - Backlog Wave A7 + A7b:
cashpaya-integration-backlog.md - ADR-009 Cashpaya como primer cliente
- ADR-013 X.509 cert bjungle + tenant opt-in — provee la cert para el
tenant_rep - ADR-002 Face-MFA recovery — step-up token para autorizar firma personal
- Wallet Modelo C — donde vive la wallet key del subject
- ADR-001 Auth audiencias — credenciales para wallet vs tenant
- ADR-008 Blockchain strategy — anchoring del hash final del envelope (Fase 1)