⌘J
En esta página 7

Engineering / Firma certificada (UANATACA)

Firma certificada (UANATACA)

Cómo firma platform con valor probatorio (SigningService agnóstico del proveedor), flujo async con UANATACA, webhooks blindados e idempotencia.

Diseño aprobado, aún no implementado (sub-proyecto #3, spec preliminar). El PoC modela el flujo de firma contra una API mock; lo de abajo es cómo está diseñada la integración real con UANATACA. El repo tiene 0 PRs mergeados. El proveedor quedó cerrado (UANATACA, 13-jul; PBS descartado como Plan B).

Axel firma contratos con firma certificada por un PSCE acreditado en El Salvador (UANATACA, acreditado por la UFE/MINEC): la única con pleno valor probatorio en juicio laboral (Art. 24 de la Ley de Firma Electrónica). El resultado es un PDF PAdES nivel T, y el ciclo cierra 100% digital con el registro MTPS ante la DGT.

SigningService: frontera agnóstica del proveedor

Toda la dependencia del proveedor de firma vive detrás de una interface. El producto le habla a SigningService, no a UANATACA.

La interface

SigningService: openSession(), sign($contractId, $signers), jobStatus($jobId), closeSession(). Espejo de la API Signbox, pero sin mencionar al proveedor.

Dos implementaciones

FakeSigningService (ciclo simulado, para #1/#2 y tests) y UanatacaSigningService (real). El bind se togglea por env, sin cambiar código de consumo.

Naming agnóstico del proveedor. Las columnas son psce_session_id / provider_job_id + un enum provider, no uanataca_*. Aunque la decisión cerró con UANATACA, el naming neutral se mantiene: un PSCE acreditado puede volver a fallar, y migrar de proveedor no debe forzar a renombrar columnas y rutas con firmas vivas persistidas. UanatacaSigningService normaliza el payload del proveedor a estas columnas neutrales.

Flujo de firma async

El flujo del PSCE es asíncrono por naturaleza: open → sign → poll status → close. Eso tolera jitter de red sin requerir respuesta síncrona instantánea.

  1. openSessionabre sesión + OTP al firmante
  2. signpor contrato, dispara el job PAdES
  3. poll statuscreated → signing → completed
  4. closeSessionrecibo de sesión

El job PAdES es asíncrono: se dispara y se consulta por estado, no se espera en línea. El resultado es un PAdES nivel T.

Son dos modos con dos Optimizers Docker distintos:

ModoOptimizerPantalla
Batch (firma múltiple del patrono, un solo OTP)SignBox OptimizerPatronoBatch
Individual (empleado sin cuenta, OTP one-shot)One-Shot Optimizer + RedisEmpleadoSign

El OTP lo genera, envía y valida el SDK del PSCE (por SMS, por defecto), no Axel: el segundo factor probatorio lo controla el certificador acreditado. El lado de Axel es solo el enlace firmado (temporarySignedRoute) que enruta al empleado al flujo del PSCE. El empleado firma sin cuenta.

Webhooks blindados

El Optimizer requiere que platform exponga webhooks agnósticos del proveedor: /api/signing/callback/document (el PDF firmado) y /api/signing/callback/status (logs de estado). Tres requisitos que no se dan por asumidos:

Auth del payload, no solo del canal

El Cloudflare Tunnel autentica el origen de la conexión, pero no el contenido. Se suma HMAC del cuerpo con secreto del PSCE: un job mal enrutado no debe poder marcar un documento como firmado.

Re-validar PAdES antes de persistir

El PDF entrante se re-valida como PAdES antes de persistirlo. Un PDF que no valida no se guarda como firmado, aunque el proveedor diga “completed”.

Idempotencia del webhook. La cola es at-least-once y el PSCE puede reintentar: dedupe por request_id + unique constraint sobre signing_jobs (tenant_id, provider_job_id). El mismo webhook entregado dos veces produce una firma, no dos. Ante un timeout outbound, se reconcilia por GET del status, nunca se re-firma a ciegas.

Durabilidad del PDF firmado

El Optimizer es un SPOF stateful (Redis) y corre en otra máquina que el PITR de Laravel Cloud no cubre. El orden de operaciones se blinda contra una caída a mitad:

Firmar → backup R2 → delete

No se borra del Optimizer hasta confirmar por HEAD/checksum que el PDF está en R2 (reintento con backoff antes de cualquier delete). Además el PDF tiene un segundo hogar: también llega a platform vía el callback de documento. signing_jobs persiste el status por-contrato en Postgres (con PITR), así que un batch parcial se reanuda sin re-firmar lo ya firmado.

Modelo de datos

signing_sessions   id, tenant_id, provider, psce_session_id, correlation_id,
                   status, opened_by, timestamps
signing_jobs       id, session_id, contract_id, provider_job_id, request_id,
                   status (created|signing|completed|failed), pades_level, timestamps
                   → unique (tenant_id, provider_job_id) · índice (tenant_id, status)
contracts
  signing_level    enum(simple|certificada), default certificada
  signed_pdf_path  storage path del PDF firmado

correlation_id (UUID propio de Axel) hila el flujo a través de las tres capas (Laravel Cloud ↔ Optimizer ↔ PSCE) para depuración.

Registro MTPS

Art. 18 del Código de Trabajo

Tras firma certificada, el paquete MTPS se genera con la firma real incorporada al PDF (nota de remisión + checklist). “Marcar como presentado” requiere el checklist completo.

100% digital resuelto

La DGT acepta el documento con firma certificada (PAdES/UANATACA) en “Oportunidades Empresa”. El “100% digital” está despejado de extremo a extremo, sin condicional.

Contract test de SigningService

La misma suite contra Fake y sandbox real

El gate de “hecho” incluye un contract test que corre la misma suite contra FakeSigningService (#1/#2) y el sandbox real del PSCE (#3): un reintento idempotente no duplica, el estado failed se propaga, y un timeout no deja el contrato en estado ambiguo. Ver Testing y verificación.