Ir al contenido

ADR-013 · X.509 cert genérica bjungle + tenant opt-in

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

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-55 ya modela SigningCertificate con campos TenantID, Kind (platform|tenant), P12URI, SubjectDN, IssuerDN, Serial, SelfSigned, ValidFrom/To, IsDefault.
  • SigningMethod distingue kms (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_certificates todaví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.

AliasUsoCert asociadoAcceso IAM
alias/bjungle-corporate-signer-prodFirma corporativa por default de cualquier tenant en producciónCA colombiana acreditada (CertCámara/ANDES SCD/GSE/Camerfirma — decisión post-cotizaciones) — adquisición jguerrero PR1bseal-worker prod role only
alias/bjungle-corporate-signer-sandboxFirma corporativa en sandbox / QASelf-signed bjungle (cert de prueba) — PR3 jguerrerobseal-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-VCbmonkey-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 por business_unit del circuit (default: cert con is_default=true del 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_id matching 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 wallet
CREATE 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;
-- RLS
ALTER 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ño
CREATE 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);

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 cert
2. Si circuit.business_unit está seteado:
- Buscar cert kind=tenant + tenant_id + business_unit + is_default
3. Si no → tenant_signer_default(tenant_id):
- cert kind=tenant + tenant_id + business_unit IS NULL + is_default
4. 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-config
X-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):

  1. Validar X-API-Key + scope bseal:certificates:manage (scope nuevo).
  2. Parsear P12, extraer public cert + chain + private key.
  3. 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
  4. Importar la private key a KMS como CMK con alias alias/bjungle-tenant-signer-<tenant_slug>-prod.
  5. Subir P12 (sin private key) a S3 bjungle-bseal-certs/<tenant_id>/.
  6. INSERT row en signing_certificates con kind=tenant, is_default=true.
  7. SSM param /bjungle/qa/bseal/<tenant>_signer_kms_arn (Wave A7d ya backlog) se completa automáticamente.
  8. 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:

  1. aws kms verify --key-id <signer_cert_kms_arn> --message-type DIGEST --message <digest> --signature <sig> → cripto válida
  2. Descarga el cert public de S3 + chain → valida cadena hasta CA root
  3. Lee signer_cert_subject → confirma quién firmó (razón social tenant)
  4. 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.

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.

  • 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-certificates con el P12 + password → bjungle KMS import + insert row con kind=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.

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-cert
Authorization: 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):

  1. Validar wallet session token + step-up token reciente (face_match, purpose=signer_cert_manage, target_ref=wallet_id, single-use).
  2. Parsear P12, extraer cert + chain + private key.
  3. 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: CN del cert debe matchear wallets.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).
  4. Crear CMK con alias alias/bjungle-subject-signer-<hash(wallet_id)>-prod. Tag: wallet_id=<uuid>. Importar private key.
  5. Subir P12 (sin private key) a S3 bjungle-bseal-certs/subjects/<wallet_id>/.
  6. INSERT row signing_certificates con kind='subject', wallet_id, is_default=true.
  7. 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. Marca is_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:

PathEndpointCaso de uso
BYOK (§9)PATCH /v1/wallet/me/signer-certSubject ya tiene cert acreditado, lo sube en P12
Bjungle-issued (§9.1)POST /v1/wallet/me/signer-cert/requestSubject sin cert previo pide a bjungle uno, bjungle CA lo firma
POST /v1/wallet/me/signer-cert/request
Authorization: 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
}

Antes de emitir, el handler verifica TODOS los siguientes (else 409 subject_not_eligible con reject_reason machine-readable):

  1. KYC verified ≥ LoA3: existe ≥1 VC activa con LoA ≥ LoA3 para el wallet. Sin KYC, no hay identidad anclada al subject.
  2. face_match passed: existe un face_patterns row 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.
  3. No request pending: si ya hay subject_cert_requests con status=‘pending’ para este wallet, 409 request_in_flight.
  4. 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.
  5. Step-up reciente (≤5 min, purpose=signer_cert_manage, target=wallet_id, single-use) — mismo gate que BYOK §9.
CN = wallets.document_number -- ej. "1098765432" (NUIP CO)
O = "Bjungle Subjects" -- fixed
OU = "Personal Signing" -- fixed (diferencia de tenant_rep)
C = "CO" -- fixed
serialNumber = wallet_id (uuid) -- legal CO usa serialNumber attribute
para anclar a doc oficial
SAN(directoryName) = serialNumber=<wallet_id> -- audit trail explícito

Decisió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).

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ía GET /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.DevCASigner genera 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ños para 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) → row

State 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).

#PreguntaStatus
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.
  • 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.

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) + alert
5. 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_at en 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.

  • 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”.
  • 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/validate que 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.
  • 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.
AlternativaPor 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-serviceExternaliza 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.

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 kind
  • bseal/backend/go-bseal-api/internal/service/cert_service.go — ya existe; extender con ImportTenantCert(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. Endpoints PATCH/GET /v1/tenants/me/signer-config + admin POST /v1/admin/signing-certificates (platform-wide cert import)
  • bmonkey/backend/go-bmonkey-api/internal/http/wallet_cert_handler.gonuevo (Wave A7e). Endpoints PATCH/GET/DELETE /v1/wallet/me/signer-cert con 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.gonuevo. Validación pre-import: parse P12 + check Subject DN matches wallets.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 1h
  • bseal/backend/go-bseal-api/internal/repo/cert_repo.goGetDefaultForTenant(tenant_id, business_unit), GetDefaultForWallet(wallet_id), GetByID(id), MarkRevoked(id, time)
  • bseal/backend/go-bseal-api/internal/ocsp/ocsp_client.gonuevo. Cliente OCSP con AIA URL extraction + signed response validation + memory cache TTL=nextUpdate or 1h max
  • infrastructure/terraform/modules/kms/main.tf (o equivalente) — declarar los KMS keys + aliases prod/sandbox:
    • alias/bjungle-corporate-signer-prod
    • alias/bjungle-corporate-signer-sandbox
    • (CMK por tenant enterprise / subject opt-in se crean dinámicamente vía API, no terraform)
  • IAM policies — bseal-worker-prod role tiene kms:Sign solo sobre las CMKs declaradas con condition RequestTag/tenant_id matching (para tenant certs) y RequestTag/wallet_id matching (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 ImportTenantCert deja entrada con actor + 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 tiene signer_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_at no nulo → próxima firma falla con cert_revoked.
  • Verify externo: dado un PDF firmado, correr aws kms verify manualmente 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_at seteado + 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 con cert_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.
  • 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_certificates row cuando expira — necesario para verificar firmas históricas. Marcar expired_at y excluir del default selector, pero mantener para audit.

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

#PreguntaResolución
1CA específica para cert globalDecidir 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.
2Algoritmo cert globalRSA_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.
3KMS import vs CloudHSM dedicadoKMS 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.
4Multi-cert por tenantSí, 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.
5Anchoring blockchain del certSolo 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}.
6OCSP stapling para revocationReal-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.
7Periodo soft post-expiryHard 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).
8Subject opt-in a cert personalSí, 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.