ADR-013 · X.509 cert genérica bjungle + tenant opt-in
| 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»Hoy bseal firma docs con una KMS key RSA_2048 (alias/bjungle-bseal en
LocalStack dev, configurable en QA/prod) directamente — sin certificado
X.509 emitido por una CA. El verificador externo puede confirmar la
firma vía aws kms verify pero no puede validar una cadena de
confianza pública.
Esto es aceptable para MVP (Ley 527 de 1999 reconoce firma electrónica simple) pero insuficiente para firma electrónica avanzada con valor probatorio reforzado. Para regulación financiera CO (SFC, DIAN factura electrónica, contratos bilaterales de alta cuantía), queremos converger a un modelo con cert X.509 emitida por CA acreditada ONAC (Organismo Nacional de Acreditación de Colombia).
CAs colombianas acreditadas relevantes (2026):
- CertiCámara (Cámara de Comercio de Bogotá) — la más establecida
- ANDES SCD (Andean Soluciones de Certificación Digital)
- Gestión de Seguridad Electrónica (GSE)
- Camerfirma Colombia
Decisiones jguerrero (2026-06-05):
- #10: “Genérica bjungle + cada tenant asocia su propia si tiene + cert de prueba sandbox”. Procurement bjungle compra una global; tenants opt-in con la suya. Sandbox usa cert de prueba ya disponible.
- #5: Wallet key para personales / KMS-tenant para corporativos. Este ADR define el lado corporativo del modelo.
- #11: Multi-firma bipartita (ADR-011) es P0 Wave A. Requiere el cert tenant_rep funcional.
Estado del código existente:
bseal/backend/go-bseal-api/internal/domain/circuit.go:32-55ya modelaSigningCertificatecon camposTenantID,Kind(platform|tenant),P12URI,SubjectDN,IssuerDN,Serial,SelfSigned,ValidFrom/To,IsDefault.SigningMethoddistinguekms(hoy),pades_platform,pades_tenant.- bseal-worker hace KMS sign sobre digest SHA-256 con
MessageType=DIGEST, SigningAlgorithm=RSASSA_PKCS1_V1_5_SHA_256. - No existen los KMS aliases ni los rows de
signing_certificatestodavía — pure schema.
Procurement legal (PR1 + PR3 del backlog) está en mano de jguerrero fuera de este ADR. Lo que sí define este ADR es la arquitectura técnica una vez que esa cert exista.
Decision
Sección titulada «Decision»1. KMS keys + aliases
Sección titulada «1. KMS keys + aliases»| Alias | Uso | Cert asociado | Acceso IAM |
|---|---|---|---|
alias/bjungle-corporate-signer-prod | Firma corporativa por default de cualquier tenant en producción | CA colombiana acreditada (CertCámara/ANDES SCD/GSE/Camerfirma — decisión post-cotizaciones) — adquisición jguerrero PR1 | bseal-worker prod role only |
alias/bjungle-corporate-signer-sandbox | Firma corporativa en sandbox / QA | Self-signed bjungle (cert de prueba) — PR3 jguerrero | bseal-worker qa role + dev mode |
alias/bjungle-wallet-signer-prod (existente) | Firma de VCs + wallet key para acto personal del subject (path default — sin opt-in cert personal) | did:web bjungle, ya en uso para JWT-VC | bmonkey-api + bseal-worker prod |
Para tenants enterprise que opten-in a su cert (BYOK custodial):
- KMS key separada por tenant (CMK) gestionada por bjungle, alias
alias/bjungle-tenant-signer-<tenant_slug>-prod. IAM lo limita al bseal-worker prod role para ese tenant_id. - Multi-cert por tenant soportado desde MVP (OQ4): un tenant puede
tener N certs si tiene varias unidades de negocio. Aliases siguen el
patrón
alias/bjungle-tenant-signer-<tenant_slug>-<unit_slug>-prod. Selector elige porbusiness_unitdel circuit (default: cert conis_default=truedel tenant). - Importante: bjungle gestiona la KMS key, no el tenant. El tenant provee la cert (P12 o PEM) emitida por SU CA preferida, bjungle la importa como key material (KMS import key, FIPS 140-2 Level 3 HSM-backed — OQ3) en una CMK aliasada al tenant.
Para subjects que opten-in a su cert personal (BYOK custodial, ver §9):
- CMK por subject con alias
alias/bjungle-subject-signer-<wallet_id_hash>-prod(hash truncado del wallet_id para mantener alias<256 chars). - IAM scoped al bseal-worker prod role + condición
kms:ResourceTag/wallet_idmatching el wallet del subject que firma. - Mismo modelo BYOK custodial: bjungle importa la private key una vez, subject no exporta.
2. Tabla bseal.signing_certificates (existente, formalizada)
Sección titulada «2. Tabla bseal.signing_certificates (existente, formalizada)»El schema ya existe en domain/circuit.go:32-55 y debería tener su
migration. MVP:
CREATE TABLE bseal.signing_certificates ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid, -- NULL = platform-wide o subject wallet_id uuid, -- NOT NULL solo si kind='subject' kind text NOT NULL CHECK (kind IN ('platform','tenant','subject')), business_unit text, -- opcional, solo para kind='tenant' con multi-cert name text NOT NULL, kms_arn text NOT NULL, -- AWS KMS ARN (no alias) kms_alias text, -- alias name p12_uri text, -- S3 URI del PKCS#12 + cert chain (sin private key) subject_dn text NOT NULL, -- CN del cert issuer_dn text NOT NULL, -- CN del issuer (CA) serial text NOT NULL, self_signed boolean NOT NULL DEFAULT false, valid_from timestamptz, valid_to timestamptz, is_default boolean NOT NULL DEFAULT false, -- default dentro de su scope (platform/tenant/subject) revoked_at timestamptz, -- non-null si revocado (OCSP check semanal o manual) created_at timestamptz NOT NULL DEFAULT now(),
-- Coherencia kind/scope CONSTRAINT kind_scope_consistency CHECK ( (kind = 'platform' AND tenant_id IS NULL AND wallet_id IS NULL) OR (kind = 'tenant' AND tenant_id IS NOT NULL AND wallet_id IS NULL) OR (kind = 'subject' AND wallet_id IS NOT NULL) ));
-- Solo una platform cert default activa (vigente)CREATE UNIQUE INDEX only_one_default_platform ON bseal.signing_certificates (kind) WHERE kind = 'platform' AND is_default = true AND revoked_at IS NULL;
-- Solo una tenant cert default por (tenant, business_unit)CREATE UNIQUE INDEX only_one_default_per_tenant_unit ON bseal.signing_certificates (tenant_id, COALESCE(business_unit, '')) WHERE kind = 'tenant' AND is_default = true AND revoked_at IS NULL;
-- Solo una subject cert default activa por walletCREATE UNIQUE INDEX only_one_default_per_wallet ON bseal.signing_certificates (wallet_id) WHERE kind = 'subject' AND is_default = true AND revoked_at IS NULL;
-- RLSALTER TABLE bseal.signing_certificates ENABLE ROW LEVEL SECURITY;ALTER TABLE bseal.signing_certificates FORCE ROW LEVEL SECURITY;
-- Platform certs visibles a TODOS los tenants (read-only)CREATE POLICY platform_certs_read ON bseal.signing_certificates FOR SELECT USING (kind = 'platform');
-- Tenant certs solo visibles al tenant dueñoCREATE POLICY tenant_certs_scope ON bseal.signing_certificates USING (kind = 'tenant' AND tenant_id = current_setting('app.current_tenant', true)::uuid);
-- Subject certs accesibles solo vía SECURITY DEFINER helpers-- (las sesiones wallet no tienen app.current_tenant; el bseal-worker-- resuelve via wallet_id explícito desde el envelope context).-- Patrón global, igual que wallets table (CLAUDE.md gotcha #7).CREATE POLICY subject_certs_deny ON bseal.signing_certificates FOR ALL USING (false) WITH CHECK (false);3. Selección de cert al firmar
Sección titulada «3. Selección de cert al firmar»bseal-worker, cuando procesa un EnvelopeSigner, determina qué key/cert
usar según el rol:
Para role=tenant_rep:
1. Mirar circuit.SigningCertID (referencia explícita) - Si está seteado → usar esa cert2. Si circuit.business_unit está seteado: - Buscar cert kind=tenant + tenant_id + business_unit + is_default3. Si no → tenant_signer_default(tenant_id): - cert kind=tenant + tenant_id + business_unit IS NULL + is_default4. Si no → platform default (kind=platform + is_default)5. Si environment=sandbox → siempre forzar 'alias/bjungle-corporate-signer-sandbox' (no se usa cert real en QA, incluso si tenant tiene opt-in)Para role=subject:
1. Resolver subject_signer_pref(wallet_id) via SECURITY DEFINER helper: - Si subject hizo opt-in a su cert personal (kind=subject + wallet_id + is_default) → usar esa cert (alias/bjungle-subject-signer-<hash>-prod)2. Si no → wallet key default ([ADR-011](./adr-011-multi-firma-bseal) §3.a — bmonkey POST /v1/wallet/me/sign-document, alias/bjungle-wallet-signer-prod)3. En sandbox: siempre wallet key default (no se usa cert real, igual que tenants)En ambos paths, antes de Sign:
- Verificar
revoked_at IS NULL(OCSP check real-time — OQ6, ver §10) - Verificar
valid_from < now() < valid_to - Si expirado o revocado → hard fail (OQ7), no firmar, alert
4. Endpoint PATCH /v1/tenants/me/signer-config
Sección titulada «4. Endpoint PATCH /v1/tenants/me/signer-config»Permite al tenant enterprise hacer opt-in a su cert propia:
PATCH /v1/tenants/me/signer-configX-API-Key: dj_live_<tenant>Content-Type: application/json
{ "use_own_cert": true, "cert_name": "Cashpaya SAS - Authoritative Signer 2026", "p12_base64": "...", -- PKCS#12 con cert + chain (sin private key extractable) "p12_password": "...", -- temp, solo durante el import "cert_chain_pem": "..." -- alternativa: solo public cert + chain (caso bring-CA-issued-pub-cert)}Flujo del handler (Wave A7c-A7d):
- Validar X-API-Key + scope
bseal:certificates:manage(scope nuevo). - Parsear P12, extraer public cert + chain + private key.
- Validar:
- Cert no expirado (
valid_from < now() < valid_to) - Issuer DN está en allowlist (CA reconocida por ONAC CO)
- Subject DN matchea razón social del tenant
- Cert no expirado (
- Importar la private key a KMS como CMK con alias
alias/bjungle-tenant-signer-<tenant_slug>-prod. - Subir P12 (sin private key) a S3
bjungle-bseal-certs/<tenant_id>/. - INSERT row en
signing_certificatesconkind=tenant, is_default=true. - SSM param
/bjungle/qa/bseal/<tenant>_signer_kms_arn(Wave A7d ya backlog) se completa automáticamente. - Response 200: cert metadata + KMS ARN para confirmación.
Endpoint complementario GET /v1/tenants/me/signer-config retorna la
config actual (qué cert se usa).
5. Persistencia de signer_cert_subject por firma
Sección titulada «5. Persistencia de signer_cert_subject por firma»Cada firma de tenant_rep en envelope_signers persiste el CN del cert
usado:
ALTER TABLE bseal.envelope_signers ADD COLUMN signer_cert_subject text, -- CN del cert (audit) ADD COLUMN signer_cert_kms_arn text, -- ARN exacto (para verify externo) ADD COLUMN signer_cert_serial text; -- serial (para revocation check)Esto permite auditoría externa: dado un PDF + signed_documents row, un verificador valida:
aws kms verify --key-id <signer_cert_kms_arn> --message-type DIGEST --message <digest> --signature <sig>→ cripto válida- Descarga el cert public de S3 + chain → valida cadena hasta CA root
- Lee
signer_cert_subject→ confirma quién firmó (razón social tenant) - Lee
signer_cert_serial→ consulta CRL/OCSP de la CA para confirmar no revocado
Sin estos 3 campos, el step 3 y 4 son imposibles desde fuera.
6. Allowlist de CAs colombianas
Sección titulada «6. Allowlist de CAs colombianas»Hard-coded en bseal/backend/go-bseal-api/internal/service/cert_service.go:
var allowedCAs = map[string]bool{ "CN=AC SUB CERTICAMARA, O=CERTICAMARA S.A.": true, "CN=AC ANDES SCD, O=ANDES SCD": true, "CN=AC GSE, O=GSE": true, "CN=AC CAMERFIRMA, O=CAMERFIRMA": true, // sandbox/dev "CN=Bjungle Sandbox CA, O=Bjungle SAS": true,}Si un tenant envía un P12 con IssuerDN que no esté en allowlist:
rechazado HTTP 400 cert_issuer_not_recognized.
La allowlist se actualiza vía deploy (nuevo CA = nueva versión). Si en
el futuro hay >10 CAs, migrar a tabla bseal.allowed_cas + endpoint
admin para mantenerla.
7. Lifecycle del cert global bjungle
Sección titulada «7. Lifecycle del cert global bjungle»- Adquisición: PR1 jguerrero (procurement) compra cert RSA_4096 signing extended-validation con CA colombiana. ~USD 200-500/año.
- Validez típica: 1-2 años. Renovación: 60 días antes de expiry, alert + procurement de renewal.
- Importación: jguerrero (admin con role específico) usa endpoint
admin one-shot
POST /v1/admin/signing-certificatescon el P12 + password → bjungle KMS import + insert row conkind=platform, is_default=true. - Revocación accidental: si CertCámara revoca nuestro cert (caso raro), el worker bseal-worker detecta vía OCSP check semanal y desactiva temporalmente firma corporativa hasta sustitución. Alerta CRITICAL.
8. Self-signed sandbox cert
Sección titulada «8. Self-signed sandbox cert»Cert PR3 ya disponible según procurement notes. Es:
- RSA_2048 (suficiente para test)
- Subject:
CN=Bjungle Sandbox Authoritative Signer, O=Bjungle SAS, C=CO - Issuer:
CN=Bjungle Sandbox CA, O=Bjungle SAS(self-issued) - Valid: ~10 años (no rotamos sandbox)
- Importado a
alias/bjungle-corporate-signer-sandbox
En sandbox nunca se importa cert real de tenant. Si un tenant prueba en QA, usa la cert sandbox bjungle. El opt-in solo aplica en prod.
9. Subject opt-in a su cert personal (OQ8 resuelta — Wave A)
Sección titulada «9. Subject opt-in a su cert personal (OQ8 resuelta — Wave A)»Subjects que tienen cert personal emitido por CA acreditada (CertCámara, ANDES SCD, FNMT EU, etc.) pueden hacer opt-in para que sus firmas personales usen ese cert en lugar del wallet key default. Esto eleva la firma de electrónica simple (wallet key bjungle) a electrónica avanzada con valor probatorio reforzado (Ley 527 + Decreto 2364).
Caso típico: abogado, contador, revisor fiscal, representante legal, comerciante inscrito firmando contratos personales en una plataforma legal-tech bjungle-powered. Aproximación cashpaya MVP: la feature está disponible pero es opcional — los 50 users productivos B2C probablemente no tienen cert personal y siguen con wallet key.
Endpoint nuevo en bmonkey:
PATCH /v1/wallet/me/signer-certAuthorization: Bearer <wallet_session_token>X-Step-Up-Token: <step_up_token con purpose=signer_cert_manage>Content-Type: application/json
{ "p12_base64": "...", "p12_password": "...", "cert_chain_pem": "..." -- alternativa: solo public cert}Flujo del handler (Wave A — feature opt-in, NO bloqueante para cashpaya):
- Validar wallet session token + step-up token reciente (face_match,
purpose=
signer_cert_manage, target_ref=wallet_id, single-use). - Parsear P12, extraer cert + chain + private key.
- Validar:
- Cert no expirado.
- Issuer DN en allowlist §6 (misma allowlist, no diferencia subject vs tenant — las CAs CO acreditadas emiten tanto personales como corporativas).
- Subject DN matchea identidad del wallet:
CNdel cert debe matchearwallets.full_name(full match exact post-normalization unicode/whitespace). Defensa anti-spoof crítica — sin esto un subject podría importar el cert de OTRO individuo y firmar en su nombre. - Cert serial no ya importado (anti-replay).
- Crear CMK con alias
alias/bjungle-subject-signer-<hash(wallet_id)>-prod. Tag:wallet_id=<uuid>. Importar private key. - Subir P12 (sin private key) a S3
bjungle-bseal-certs/subjects/<wallet_id>/. - INSERT row
signing_certificatesconkind='subject',wallet_id,is_default=true. - Audit log entry con
actor=wallet:<wallet_id>,cert_serial,cert_subject_dn,ip,user_agent.
Endpoints complementarios:
GET /v1/wallet/me/signer-cert— retorna metadata del cert activo (sin private key).DELETE /v1/wallet/me/signer-cert— revoca el opt-in. Requiere step-up. Marcais_default=false+revoked_at=now(). Firmas históricas siguen verificables. Wallet vuelve al wallet key default.
Sandbox: el endpoint acepta los mismos P12 pero la importación va a
sandbox KMS y el alias incluye -sandbox suffix. Nunca cross-environment.
9.1 Subject cert emitido internamente por bjungle (OQ8.1 — amendment 2026-06-06)
Sección titulada «9.1 Subject cert emitido internamente por bjungle (OQ8.1 — amendment 2026-06-06)»§9 cubre el caso BYOK (Bring Your Own Certificate): el subject ya tiene un cert personal emitido por una CA acreditada CO y lo sube a bjungle. Eso sirve al profesional con cert de CertiCámara/ANDES SCD, pero deja fuera al subject KYC-verificado sin cert personal, que es el caso típico cashpaya B2C (50 users sin cert previo).
Para cerrar ese hueco, agregamos un segundo path donde bjungle actúa como CA interna (no acreditada ONAC todavía — ver §9.1.6) y emite un cert X.509 personal al subject. Esto NO reemplaza el flujo BYOK §9; ambos coexisten:
| Path | Endpoint | Caso de uso |
|---|---|---|
| BYOK (§9) | PATCH /v1/wallet/me/signer-cert | Subject ya tiene cert acreditado, lo sube en P12 |
| Bjungle-issued (§9.1) | POST /v1/wallet/me/signer-cert/request | Subject sin cert previo pide a bjungle uno, bjungle CA lo firma |
9.1.1 Endpoint
Sección titulada «9.1.1 Endpoint»POST /v1/wallet/me/signer-cert/requestAuthorization: Bearer <wallet_session_token>X-Step-Up-Token: <step_up_token con purpose=signer_cert_manage>Content-Type: application/json
{ "common_name_override": null -- opcional; default = wallet.document_number}
→ 201 Created{ "request_id": "uuid", "status": "issued", "common_name": "1098765432", "bseal_cert_id": "uuid", "cert_serial": "...", "subject_dn": "CN=1098765432, OU=Personal Signing, O=Bjungle Subjects, C=CO", "issuer_dn": "CN=Bjungle Internal CA, O=Bjungle SAS, C=CO", "valid_from": "...", "valid_to": "..." -- 1 año desde issue}9.1.2 Gates obligatorios (fail-closed)
Sección titulada «9.1.2 Gates obligatorios (fail-closed)»Antes de emitir, el handler verifica TODOS los siguientes (else 409
subject_not_eligible con reject_reason machine-readable):
- KYC verified ≥ LoA3: existe ≥1 VC activa con
LoA ≥ LoA3para el wallet. Sin KYC, no hay identidad anclada al subject. - face_match passed: existe un
face_patternsrow activo para el wallet. Sin face_match, el subject no tiene anclaje biométrico — emitir un cert sería elevar la firma sobre una identidad no presencialmente verificada. - No request pending: si ya hay
subject_cert_requestscon status=‘pending’ para este wallet, 409request_in_flight. - No cert activo no revocado: si ya hay un cert kind=‘subject’,
is_default=true, revoked_at IS NULL para este wallet, 409
cert_already_active. El subject debe revocar primero. - Step-up reciente (≤5 min, purpose=
signer_cert_manage, target=wallet_id, single-use) — mismo gate que BYOK §9.
9.1.3 Subject DN del cert emitido
Sección titulada «9.1.3 Subject DN del cert emitido»CN = wallets.document_number -- ej. "1098765432" (NUIP CO)O = "Bjungle Subjects" -- fixedOU = "Personal Signing" -- fixed (diferencia de tenant_rep)C = "CO" -- fixedserialNumber = wallet_id (uuid) -- legal CO usa serialNumber attribute para anclar a doc oficialSAN(directoryName) = serialNumber=<wallet_id> -- audit trail explícitoDecisión: CN=document_number en lugar de full_name — alinea con el estándar CO de “firma electrónica con identificación oficial” (Decreto 2364 art 6). El verificador externo lee CN, lo cruza contra el registro civil público, y confirma identidad sin depender de comparación de nombres (ambigua con homónimos).
9.1.4 Issuer = CA interna bjungle
Sección titulada «9.1.4 Issuer = CA interna bjungle»Issuer firmante:
- prod: KMS alias
alias/bjungle-internal-ca-prod— RSA_4096 (mismo algoritmo que cert tenant per OQ2). Reutiliza la infra de KMSImporter existente para tenant kind (ya provisionada — ver §1 KMS keys + aliases). El cert root de la CA interna se publica víaGET /v1/internal-ca/cert.pem(zero-auth, distribuible a verificadores). - dev/sandbox:
alias/bjungle-internal-ca-sandbox, self-signed root, válido 10 años. Implementación dev:service.DevCASignergenera el par CA al startup del proceso (NUNCA en prod —NewDevCASigner("prod")hard-falla).
No es CA acreditada ONAC — la firma resultante es electrónica simple según Ley 527 (no avanzada). Para elevar a avanzada en el futuro, ver §9.1.6 (open question).
Valid: 1 año desde now() (renewable). Justifications:
- Compliance: NIST SP 800-57 recomienda
<2 añospara subscriber certs. - Revocation pressure: 1 año limita la ventana de exposure si el subject pierde control del wallet (KMS custodia).
9.1.5 Tabla subject_cert_requests (state machine)
Sección titulada «9.1.5 Tabla subject_cert_requests (state machine)»Vive en bmonkey (no bseal) porque la decisión de eligibilidad se basa
en datos bmonkey (wallets, face_patterns, verifiable_credentials).
El cert final sigue persistido en bseal.signing_certificates (single
source of truth de certs activos). El request es la audit trail del
flujo de issuance.
CREATE TABLE bmonkey.subject_cert_requests ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), wallet_id uuid NOT NULL, -- requester status text NOT NULL CHECK (status IN ('pending','issued','rejected','revoked')), common_name text NOT NULL, -- document_number snapshot at request time reject_reason text, -- machine code (kyc_not_verified, ...) bseal_cert_id uuid, -- ID en bseal.signing_certificates post-issue cert_serial text, -- denormalizado para audit query rápida step_up_id uuid, -- step_up token consumed (audit link) requested_at timestamptz NOT NULL DEFAULT now(), issued_at timestamptz, revoked_at timestamptz,
-- Solo 1 request pending por wallet (no spam): CONSTRAINT one_pending_per_wallet EXCLUDE USING btree (wallet_id WITH =) WHERE (status = 'pending'));
-- RLS deny_all + SECURITY DEFINER helpers (gotcha #7 CLAUDE.md):ALTER TABLE bmonkey.subject_cert_requests ENABLE ROW LEVEL SECURITY;ALTER TABLE bmonkey.subject_cert_requests FORCE ROW LEVEL SECURITY;CREATE POLICY subject_cert_requests_deny ON bmonkey.subject_cert_requests FOR ALL USING (false) WITH CHECK (false);
-- Helpers:-- subject_cert_request_create(wallet_id, cn, step_up_id) → id-- subject_cert_request_mark_issued(id, bseal_cert_id, serial) → int-- subject_cert_request_mark_rejected(id, reason) → int-- subject_cert_request_mark_revoked(id) → int-- subject_cert_request_active_for_wallet(wallet_id) → row-- subject_cert_request_get(id, wallet_id) → rowState machine:
request_endpoint (gates pass + step-up consumed) │ ▼ ┌──────────┐ │ pending │ └────┬─────┘ │ bseal S2S issue OK ▼ ┌──────────┐ │ issued │ ── cert vigente en bseal.signing_certificates └────┬─────┘ │ POST /signer-cert/revoke o OCSP cron ▼ ┌──────────┐ │ revoked │ └──────────┘
(path terminal alternativo: 'rejected' si gates fallan post-pending)MVP: el flujo es sincrónico. pending existe como estado solo si
el bseal S2S call falla — en happy path se llega directo a issued en
el mismo request. Post-MVP: async (manual review queue para casos
high-risk SARLAFT).
9.1.6 Open questions §9.1
Sección titulada «9.1.6 Open questions §9.1»| # | Pregunta | Status |
|---|---|---|
| 9.1.a | ¿Bjungle CA interna deviene ONAC-acreditada? | Open. Mientras no, las firmas son simples no avanzadas. Procurement + audit ONAC = meses. Backlog Wave G+. |
| 9.1.b | ¿Manual review queue para issuance? | Open. MVP emite directo si gates §9.1.2 OK. Wave B+: si SARLAFT escala riesgo o si el wallet tiene anomalías recientes (passkey revoke <24h), bloquear y enviar a queue manual. |
| 9.1.c | ¿Renewal automático antes de expiry? | Open. MVP requiere el subject pedir nuevo cert a los 365 días. Wave B+: cron job notifica 30d antes + endpoint POST /v1/wallet/me/signer-cert/renew. |
| 9.1.d | ¿Coexistencia con BYOK? | Resuelta: ambos paths coexisten. is_default=true es exclusivo (UNIQUE index parcial de §2). Si el subject tiene BYOK activo, pedir bjungle-issued primero requiere revocar el BYOK explícitamente. |
| 9.1.e | ¿Per-subject CMK con import de leaf private key? | Open. MVP: el dev signer descarta la leaf private key tras firmar (las firmas posteriores aún van por wallet key default). Wave B+: importar leaf privkey a CMK alias/bjungle-subject-signer-<hash(wallet_id)> para que las firmas usen el subject cert directamente. |
9.1.7 Compliance / valor probatorio
Sección titulada «9.1.7 Compliance / valor probatorio»- Hoy (CA interna no acreditada): firma electrónica simple Ley 527 art 7. Suficiente para click-wrap, ToS, contratos de adhesión bajos. NO suficiente para alta cuantía sin gates adicionales (SARLAFT, face step-up por firma).
- Mitigaciones documentables: cada firma deja audit log con (1) cert serial, (2) face step-up token consumido, (3) KYC level del subject, (4) timestamp KMS Sign. En disputa, ese stack defiende “el subject Y consintió firmar el doc X” — equivalente práctico a avanzada para cuantías medias.
- Path a avanzada: ver §9.1.6 OQ-a. Mientras tanto, tenant enterprise que necesite avanzada para sus subjects debe usar el flujo BYOK §9 (subject sube cert CertiCámara). Bjungle-issued es para los que no tienen cert propio.
10. OCSP real-time en cada firma (OQ6)
Sección titulada «10. OCSP real-time en cada firma (OQ6)»Antes de cada Sign, bseal-worker valida el cert no esté revocado:
1. Cargar cert_serial + cert_issuer_dn de la cert seleccionada.2. Lookup OCSP responder URL desde el cert (AIA extension).3. POST OCSP request al responder de la CA con el serial.4. Validar respuesta: - OCSP response signed por la CA (trust chain válido) - Status = 'good' → proceder con Sign - Status = 'revoked' → UPDATE signing_certificates SET revoked_at=ocsp.revocation_time, hard fail Sign, alert CRITICAL - Status = 'unknown' o responder unreachable → fail-closed (no firmar) + alert5. Cache la respuesta OCSP en memoria por máx 1 hora (TTL del nextUpdate de la respuesta).Trade-off: +50-200ms de latencia por firma + dependency en disponibilidad de OCSP responder de la CA. Mitigación:
- Cache 1h por (kms_arn, serial) — la mayoría de firmas en un tenant activo reusan cert reciente.
- Sandbox: cert self-signed bjungle no tiene OCSP responder. Skip
check en sandbox (
environment != 'prod' → skip). - OCSP responder de la CA caído → fail-closed para CA prod (no firmar + retry con backoff exponencial). NUNCA fail-open. Aceptamos el down-time porque CA acreditada CO tiene SLA contractual.
- Cron secundario (no reemplaza el real-time): job semanal scanea
todos los certs activos y refresca
revoked_aten bulk. Sirve para consistencia + alertas proactivas si CA revoca mientras no se firma.
Costo: ~USD 0 (OCSP query es free). Latencia es el único trade-off.
Consequences
Sección titulada «Consequences»Positivas
Sección titulada «Positivas»- Cumple legal CO firma avanzada: con cert de CA acreditada, nuestros docs cashpaya tienen valor probatorio reforzado (Ley 527 + decreto 2364 de 2012). Sin esto, en una disputa de alta cuantía la defensa de la contraparte podría argumentar firma electrónica simple.
- Modelo “BYO cert” para enterprise: tenants grandes (banco, EPS) ya tienen relación con CertCámara/CertiSur. Pueden usar SU cert sin procurement adicional. Reduce fricción onboarding enterprise.
- Custodia central de private keys: nadie fuera del bseal-worker prod role accede a las private keys. IAM least-privilege + CloudTrail registra cada KMS Sign. Auditable.
- Verificación externa sin acceso a bjungle: con
kms_arn+ cert chain en S3 público + serial, un juez puede verificar la firma sin llamar a nuestras APIs. Defensible. - Frontera clara wallet vs cert: wallet key del subject (personal) es did:web bjungle, no usa cert X.509 público. Cert X.509 es solo para acto corporativo (tenant_rep firmando). Eso evita confusión legal de “el subject firmó como persona o como rep de tenant”.
Negativas / trade-offs
Sección titulada «Negativas / trade-offs»- Costo recurrente de cert global: ~USD 200-500/año. Trivial pero recurrente.
- Costo recurrente CMK por tenant enterprise: USD 1/mes por CMK + USD 0.03 per 10k requests sign. Para 10 tenants enterprise = USD 10-15/mes flat + variable. Aceptable.
- Operational complexity al importar P12 del tenant: validar P12,
password handling temporal, KMS import — flujo sensitivo. Si el
P12 viene corrupto o el password mal, el tenant tiene UX confuso.
Mitigación: validación stepwise + endpoint
POST /v1/tenants/me/signer-config/validateque valida sin importar (dry-run). - Renewal manual del cert global: 60 días antes hay que correr el proceso. Si jguerrero olvida → cert expira → firmas corporativas fallan. Mitigación: CloudWatch alarm 90/60/30 días pre-expiry + runbook documentado.
- CRL/OCSP check no implementado en MVP: si CA revoca el cert, bseal-worker no se entera hasta polling manual. Backlog: implementar OCSP stapling semanal. Riesgo bajo (revocaciones de cert válido son raras).
- Self-signed sandbox no es “valid” para verifier externo: un partner que pruebe nuestro flow en QA no podrá validar con CA pública. Aceptable porque es sandbox, pero hay que documentar.
Neutrales
Sección titulada «Neutrales»- Cert tenant_rep es del tenant, no del operator humano: el cert representa a la razón social (cashpaya SAS), no a la persona que firma en su nombre (operator). El audit dice “Cashpaya SAS firmó con operator=jperez@cashpaya.co” — la persona es informativa, el cert es la entidad legal.
- Cert sandbox bjungle reusable para múltiples tenants en QA. No hay aislamiento de certs en QA — todos comparten. Aceptable porque sandbox no produce firma legalmente vinculante.
Alternatives considered
Sección titulada «Alternatives considered»| Alternativa | Por qué se descartó |
|---|---|
| Cert por tenant obligatoria (cada tenant trae su cert al onboardear) | Costo prohibitivo para tenants pequeños (USD 200+ procurement + tiempo). Cierra puerta a fintechs nuevas que no tienen cert. Default global bjungle elimina la barrera. |
| Self-signed only (sin CA pública) | No aceptable legal CO para firma avanzada. Funciona para ToS click-wrap pero no para contratos bilaterales de alta cuantía. cashpaya pidió firma con valor probatorio reforzado. |
Reusar alias/bjungle-bseal RSA_2048 actual | (1) RSA_2048 no cumple SFC recomendación de RSA_4096 para firma legal financiera. (2) No tiene cert X.509 emitido por CA. (3) La key es genérica, no diferencia “platform signer” vs “wallet VC signer” — mezcla audiencias. |
| PKI propia bjungle (CA emite bjungle, root cert bjungle) | Posible pero requiere que verificadores externos importen nuestra root CA — fricción enorme. CAs públicas acreditadas son trust-by-default. Solo tiene sentido si bjungle deviene una CA acreditada nosotros mismos (procurement de meses + audit ONAC). No es MVP. |
| Stripe / DocuSign sign-as-a-service | Externaliza cert + custodia + lifecycle, pero (a) costo recurrente alto, (b) data leakage del PDF a 3rd party, (c) no integra con wallet SSI. Mismo argumento que ADR-011 alternatives. |
| Subject firma como tenant_rep también (con su cert personal CA-emitido) | Algunas regulaciones CO requieren que el rep legal firme con SU cert personal. Modelo válido pero complejo: requiere que el operator humano tenga cert personal de CA. Caro y poco escalable. Default usa cert corporativa del tenant, no del operator individual. |
Implementation notes
Sección titulada «Implementation notes»Files a tocar (Wave A7c-A7e):
bseal/backend/db-bseal/migrations/0008_signing_certificates.up.sql— formalizar tabla + RLS + 3 indexes UNIQUE para defaults por kindbseal/backend/go-bseal-api/internal/service/cert_service.go— ya existe; extender conImportTenantCert(p12, password, tenant, business_unit),ImportSubjectCert(p12, password, wallet_id),ValidateCertChain,ResolveSignerCert(circuit, signer),CheckOCSPRevocation(cert),validateSubjectDNMatchesWallet(cert, wallet)bseal/backend/go-bseal-api/internal/http/cert_handler.go— nuevo. EndpointsPATCH/GET /v1/tenants/me/signer-config+ adminPOST /v1/admin/signing-certificates(platform-wide cert import)bmonkey/backend/go-bmonkey-api/internal/http/wallet_cert_handler.go— nuevo (Wave A7e). EndpointsPATCH/GET/DELETE /v1/wallet/me/signer-certcon Bearer wallet session + step-up gate. Llama bseal-api via S2S para el import KMS efectivo.bmonkey/backend/go-bmonkey-api/internal/service/wallet_cert_service.go— nuevo. Validación pre-import: parse P12 + check Subject DN matcheswallets.full_name+ step-up consume + audit log.bseal/backend/go-bseal-worker/internal/worker/signing.go— selector de KMS ARN basado en cert resolution (3 kinds) + OCSP real-time check antes de Sign + cache 1hbseal/backend/go-bseal-api/internal/repo/cert_repo.go—GetDefaultForTenant(tenant_id, business_unit),GetDefaultForWallet(wallet_id),GetByID(id),MarkRevoked(id, time)bseal/backend/go-bseal-api/internal/ocsp/ocsp_client.go— nuevo. Cliente OCSP con AIA URL extraction + signed response validation + memory cache TTL=nextUpdate or 1h maxinfrastructure/terraform/modules/kms/main.tf(o equivalente) — declarar los KMS keys + aliases prod/sandbox:alias/bjungle-corporate-signer-prodalias/bjungle-corporate-signer-sandbox- (CMK por tenant enterprise / subject opt-in se crean dinámicamente vía API, no terraform)
- IAM policies —
bseal-worker-prodrole tienekms:Signsolo sobre las CMKs declaradas con conditionRequestTag/tenant_idmatching (para tenant certs) yRequestTag/wallet_idmatching (para subject certs, validado contra wallet_id del EnvelopeSigner).
Patrones a respetar:
- RLS scoped read: platform certs visibles a todos, tenant certs solo al tenant dueño. Ver §2 del schema.
- SECURITY DEFINER helpers para que bseal-worker (sin tenant context) acceda al cert resolution sin bypass-ear RLS manualmente.
- Audit log: cada
ImportTenantCertdeja entrada conactor + tenant_id + cert_serial + cert_subject_dn + ip. Append-only. - No persistir P12 password: solo se usa durante el import, inmediatamente descartado. Nunca a logs.
Tests obligatorios:
- Cert global sandbox: bseal-worker en QA firma con
alias/bjungle-corporate-signer-sandbox, signed_document tienesigner_cert_subject= “CN=Bjungle Sandbox Authoritative Signer”. - Tenant opt-in flow: dummy CA test, generar cert P12, hacer PATCH, verificar (1) KMS key creada con alias correcto, (2) row insertada, (3) próxima firma del tenant usa esa cert.
- CA no allowlisted: P12 con issuer DN random → 400.
- Cert expirado: P12 con
valid_to < now()→ 400. - Subject DN no matchea tenant: P12 con CN = “ACME” pero tenant es “Cashpaya” → 400 + audit warning.
- Cert revocation simulation: marcar
signing_certificates.revoked_atno nulo → próxima firma falla concert_revoked. - Verify externo: dado un PDF firmado, correr
aws kms verifymanualmente con el ARN persistido → cripto OK. only_one_default_platform: intentar INSERT segunda platform cert con is_default=true → constraint fails.- Multi-cert por tenant (OQ4): tenant con cert “Pagos” + cert
“Inversiones” → envelope con
circuit.business_unit='inversiones'selecciona la cert de Inversiones; envelope sin business_unit usa la default. - Subject opt-in happy path (OQ8): wallet con full_name=“Jonhjar Guerrero” hace PATCH con P12 cert CN=“Jonhjar Guerrero” → import OK, próxima firma personal usa subject cert + audit log entry.
- Subject DN mismatch: wallet full_name=“Jonhjar Guerrero” intenta importar P12 con CN=“Pedro Perez” → 400 + audit warning + cert NO importado.
- Subject opt-in sin step-up: PATCH sin X-Step-Up-Token → 401.
- Subject revoke opt-in: DELETE /signer-cert →
is_default=false+revoked_atseteado + KMS key marked for deletion (30d). Próxima firma personal usa wallet key default. - OCSP responder caído: mock OCSP timeout → Sign fails closed + alert (NO firma con cert silently). Cache hit no se afecta.
- OCSP returns revoked: mock OCSP status=revoked → UPDATE
revoked_at+ alert CRITICAL + Sign fails concert_revoked. - OCSP cache TTL: dos firmas consecutivas en <1h con misma cert → solo 1 query OCSP a la CA (verificar con mock counter).
- OCSP skip en sandbox: cert self-signed sandbox → no OCSP query (no AIA extension), Sign procede.
Riesgos a vigilar:
- P12 password leak: durante el import el password está en memoria Go un instante. Asegurar zero-copy + GC clear post-import.
- KMS import key blast radius: si un dev confunde environments y intenta importar prod cert en QA → MFA admin gate + dual approval.
- Cert chain incompleta: si el P12 no incluye intermediate CAs, la verificación externa falla. Validar chain en el import + reject si chain rota.
- Tenant cambia cert frecuentemente: cada cambio = nueva CMK, vieja queda en KMS pendiente delete (7-30 días). Costo creciente. Política: max 4 cambios/año, exceso requiere ticket support.
Compliance / regulatory considerations
Sección titulada «Compliance / regulatory considerations»- Ley 527 de 1999 + Decreto 2364 de 2012 (firma electrónica Colombia): firma electrónica avanzada con cert de CA acreditada ONAC cumple el nivel más alto de valor probatorio. Nuestro modelo encaja.
- Decreto 333 de 2014 (entidades de certificación digital): define los requisitos de CAs acreditadas. La allowlist del §6 debe alinear. jguerrero confirma con counsel el set definitivo pre-prod.
- eIDAS QES (UE): si en el futuro un tenant UE quiere QES (Qualified
Electronic Signature), requiere cert de CA Qualified Trust Service
Provider. Modelo extensible — agregar CAs EU a allowlist + variante de
signing_method=pades_qes. - PCI / SOC2: la custodia de private keys en KMS HSM-backed cumple controles SOC2 CC6 + ISO 27001 A.10. Si auditoría pide evidencia, hay CloudTrail logs de cada Sign + KeyPolicy.
- DIAN factura electrónica: la cert para firma DIAN es distinta de esta. DIAN requiere cert específico DIAN-emitido por contribuyente. No mezclar con este flow. bjungle factura electrónica al tenant va por proveedor externo (Carvajal/Facture, ver ADR-010 §5).
- Retención de certs vencidos: no purgar
signing_certificatesrow cuando expira — necesario para verificar firmas históricas. Marcarexpired_aty excluir del default selector, pero mantener para audit.
Open questions
Sección titulada «Open questions»Resoluciones jguerrero 2026-06-05 — todas cerradas para Accepted.
| # | Pregunta | Resolución |
|---|---|---|
| 1 | CA específica para cert global | Decidir post-cotizaciones. Procurement PR1 jguerrero solicita cotización a las 4 CAs ONAC acreditadas (CertCámara, ANDES SCD, GSE, Camerfirma) y elige por costo/SLA/soporte API. Allowlist §6 incluye las 4 desde MVP — el switch entre ellas no requiere code change. |
| 2 | Algoritmo cert global | RSA_4096. Estándar conservador, máxima compatibilidad con verificadores legacy CO (algunos juzgados/notarías aún no soportan ECDSA). Reabrir 2028+ si llega case de performance crítico. |
| 3 | KMS import vs CloudHSM dedicado | KMS import key. FIPS 140-2 Level 3 HSM-backed via AWS KMS. Suficiente para volumen actual. ~USD 1/mes por CMK. Reabrir si volume >1M firmas/mes o si auditor pide CloudHSM dedicado explícitamente. |
| 4 | Multi-cert por tenant | Sí, soportar desde MVP. Schema §2 con columna business_unit opcional + UNIQUE por (tenant_id, business_unit) en defaults. Selector §3 elige por business_unit del circuit. Cashpaya MVP usa una sola cert (sin business_unit), pero la feature está lista para tenants con múltiples unidades. |
| 5 | Anchoring blockchain del cert | Solo para tenants enterprise opt-in. Tier Enterprise (ADR-010) incluye opcionalmente anchoring del hash (kms_arn + cert_subject + serial + valid_from) a Polygon (extension ADR-008 fase 2). Free/Starter/Growth confían en CloudTrail + KMS attestation. Tenant activa con PATCH /v1/tenants/me/signer-config {blockchain_anchor: true}. |
| 6 | OCSP stapling para revocation | Real-time OCSP en cada firma. Ver §10 nuevo. +50-200ms latencia + cache 1h + fail-closed si responder caído. NO fail-open en prod (CA acreditada tiene SLA). Cron secundario semanal para refresh proactivo + alertas. |
| 7 | Periodo soft post-expiry | Hard fail. Cualquier firma con cert expirado es legalmente impugnable. Bloquear duro sin warning + alarms CloudWatch 90/60/30/7 días pre-expiry + runbook documentado. Fallback NO al cert sandbox (peor UX legal). |
| 8 | Subject opt-in a cert personal | Sí, incluir en Wave A (Wave A7e — nuevo subtask). Ver §9 nuevo. Endpoint PATCH /v1/wallet/me/signer-cert con step-up + validación Subject DN == wallets.full_name. Cashpaya MVP no lo usará (50 users B2C) pero queda listo. Client-side signing PKCS#11/WASM queda como open question backlog para post-launch. |
Open questions reabiertas a futuro (no bloquean Accept)
Sección titulada «Open questions reabiertas a futuro (no bloquean Accept)»- Client-side signing para subjects (PKCS#11/WASM, llave nunca sale del device): ~3-4 semanas R&D. Resuelve la objeción “no quiero entregar mi private key a bjungle” pero requiere dependencias browser/OS no triviales (Smart Card Reader API, WebUSB, Yubikey support). Backlog Wave F+ cuando aparezca tenant que lo exija.
- Migración bjungle a CA acreditada (ser nosotros mismos una CA ONAC): procurement de meses + audit ONAC + capital significativo. Solo tiene sentido si bjungle se posiciona como certificadora. Out of scope hoy.
Cross-references
Sección titulada «Cross-references»- Gap analysis decisión #10 + #11 (X.509 + multi-firma):
integracion-cashpaya-gap-analysis.md - Backlog procurement PR1-PR3 + Wave A7c-A7d:
cashpaya-integration-backlog.md - ADR-009 Cashpaya como primer cliente
- ADR-011 Multi-firma bseal — consumer principal de este modelo
- ADR-008 Blockchain strategy — anchoring futuro de certs
- ADR-004 API key hardening — scope
bseal:certificates:manage - Firma electrónica con bseal — contexto general bseal