think
16px
820px

Internal e-signature: accountable-enough hardening — Design

Date: 2026-07-02
Status: Approved (design); pending implementation plan.

Goal

Make Obscura's free Internal e-signature tier "accountable enough" — cheap, disposable, but
trustworthy inside the org — now that Peruri is the paid certified (tersertifikasi) e-sign +
e-meterai provider. Four pieces: a self-hosted RFC3161 TSA (PAdES B-T timestamps), an honest
verifier
(replacing the always-true bool), signing-key encryption at rest (plaintext PEM in
Postgres today), and trust-anchor publication (org root cert + fingerprint download).

Context (verified against code, 2026-07-02)

The internal signer (esign/adapters/pdfsign.go via digitorus/pdfsign) currently produces
PAdES B-B at best: detached CMS, SHA-256, ESS SigningCertificateV2 present, but SubFilter
/adbe.pkcs7.detached, no RFC3161 timestamp (SignData.TSA never set; /M = server
time.Now()), no /DSS/OCSP/CRL, chain = {leaf, self-signed "Obscura In-House CA"} published
nowhere. Three verified defects this design fixes or mitigates:

  1. The verifier lies. Signer.Verify(signed) (name string, valid bool, err error)
    (esign/app/service.go:88, adapter pdfsign.go:217-223) returns only s.ValidSignature; the
    library sets ValidSignature=true via a bare p7.Verify() fallback even when the chain is
    untrusted (TrustedIssuer=false is discarded). Worse, Verify is effectively dead code — the
    UI renders the DB evidence ledger written at sign time and never re-checks the PDF.
  2. Keys are plaintext in Postgres. ca_certs.key_pem and signing_certs.key_pem (migration
    00011) hold unencrypted PKCS#1 PEM (x509ca.go:132-134 encodeKeyPEM). Any DB read (dump,
    replica, SQLi) = full CA compromise = forge any employee's signature.
  3. No trusted time, no published anchor. Server-side signing already prevents user backdating,
    but the time is not portable proof; and no relying party can trust the org CA because the root
    cert is never exported anywhere.

Decisions already made by the user:
- Self-host the TSA (no Peruri TSA dependency; air-gap friendly).
- Be-our-own-anchor now (publish root + fingerprint; org distributes via MDM/GPO).
Chain-under-a-trusted-anchor is future roadmap (recorded in ROADMAP.md — ask Peruri about
sub-CA/cross-sign when the partnership matures).
- Out of scope (Peruri's certified tier covers these needs): /DSS, B-LT/B-LTA archive
timestamps, HSM/KMS, PDF/A, any change to provider (Mekari/Peruri) output, public upload-verify
portal.

Shape decisions (approved):
- TSA is in-process (not a sidecar): github.com/digitorus/timestamp is already in go.mod
(promote from indirect) and has the full server side (ParseRequest,
Timestamp.CreateResponse(cert, signer), CreateErrorResponse). pdfsign's TSA client speaks
plain HTTP, so the signer self-calls our own route.
- KEK reuses the existing at-rest master key EncryptionConfig.Key
(BLOB_ENCRYPTION_KEY_FILE, age X25519 identity, provisioned at deploy/secrets/blob_age.key)
— the requirement is only that the key lives outside Postgres. No new secret.

Component 1 — self-hosted RFC3161 TSA (B-T)

  • TSA certificate: issued off the org CA and stored like the existing
    system:obscura-attestation cert (a signing_certs row owned by system:tsa). Template
    differences from a user signing cert: ExtKeyUsage = []{ExtKeyUsageTimeStamping} marked
    critical and as the only EKU (RFC3161 §2.3 requirement), KeyUsage = DigitalSignature,
    CN "Obscura TSA". Auto-created/reissued on first use or expiry, same as user certs.
  • HTTP endpoint: POST /api/v1/tsa — unauthenticated (RFC3161 clients don't authenticate;
    same posture as the /public/sign/* routes), gated requireModule("esign"), rate-limited with
    the auth-route limiter. Request body application/timestamp-query (DER TimeStampReq) →
    timestamp.ParseRequest → build timestamp.Timestamp{HashAlgorithm/HashedMessage from request, Time: time.Now().UTC(), SerialNumber: random 128-bit (crypto/rand — uniqueness without DB state), Certificate: tsaCert, AddTSACertificate: true, Nonce: echoed when present}
    CreateResponse(tsaCert, tsaKey)200 with application/timestamp-reply. Malformed or
    unsupported request → CreateErrorResponse(RejectionStatus, ...) (still a valid RFC3161 reply,
    not an HTTP error). Oversized bodies rejected (requests are <1 KiB; cap at 10 KiB).
  • Signer wiring: NewPdfSigner(tsaURL string) (today argument-free, wire.go:246); when
    tsaURL != "" it sets SignData.TSA = sign.TSA{URL: tsaURL} and the pinned pdfsign fetches and
    embeds the token as the RFC3161 unsigned attribute automatically. New config
    ESIGN_TSA_URL (in a small ESignConfig addition or alongside the existing esign fields):
    default "" = no timestamp, today's B-B behaviour (keeps bare go run dependency-free);
    compose sets http://127.0.0.1:8080/api/v1/tsa (in-container self-call — the server listens on
    :8080).
  • Failure semantics: fail closed. If ESIGN_TSA_URL is set and the TSA call fails, the Sign
    operation fails (we never emit a signature that silently lacks the timestamp we're configured to
    apply). Since it's a self-call, TSA availability ≡ server availability.
  • Applies to every signature produced by the internal pdfsign path: user internal signatures,
    external-party attestation signatures. Provider output untouched.

Component 2 — trust-anchor publication

  • Endpoint: GET /api/v1/esign/ca — authenticated, requirePerm("document.read"),
    requireModule("esign"). Default JSON:
    {"pem": "...", "sha256_fingerprint": "AB:CD:...", "subject": "CN=Obscura In-House CA", "not_before": ..., "not_after": ...}. ?format=pem / ?format=der stream a download
    (obscura-ca.pem / .cer) for MDM/GPO import.
  • UI: an "Organization CA" card in the admin settings area (where the LicensingTab lives):
    subject, validity, SHA-256 fingerprint (copyable), PEM + DER download buttons, one line of
    guidance ("distribute to org trust stores via MDM/GPO so internal signatures verify as
    trusted").
  • Docs: docs/INTERNAL_SIGNING.md — the two-tier story (Peruri certified vs Internal
    accountable/uncertified under UU ITE/PP 71-2019), what B-T gives, how to distribute the root,
    the verify endpoint, key-at-rest behaviour, and the chain-under-trusted-anchor future.

Component 3 — honest verifier

  • Port change (esign/app/service.go): Verify(signed []byte) (domain.VerifyResult, error)
    replacing (name, valid, err). domain.VerifyResult{Signatures []domain.SignatureCheck};
    SignatureCheck{SignerName, SubjectCN string; IntegrityOK bool; IssuerTrusted bool; TimestampPresent bool; TimestampTime *time.Time; TimeSource string ("tsa"|"claimed"); ClaimedTime *time.Time; CertNotAfter time.Time}.
  • Adapter: build the verify cert pool from the org CA store (all ca_certs rows), not
    system roots — IssuerTrusted then means "chains to our CA", the meaningful question for the
    internal tier. IntegrityOK = ByteRange digest + CMS check. The bare-p7.Verify fallback maps
    to IntegrityOK=true, IssuerTrusted=false — never again collapsed into one bool. Timestamp
    fields read from the embedded RFC3161 token when present (validated against the same org pool),
    else TimeSource="claimed" with the /M date.
  • Endpoint: GET /api/v1/documents/{docID}/versions/{version}/verifyrequireModule("esign")
  • requireAccess(AccessRead). Loads the stored version bytes (OpenVersionContent) and
    re-checks the actual PDF, returning the VerifyResult (empty Signatures = "no signature
    found"). This is the re-check the DB evidence ledger never does.
  • UI: in the DocumentDetailView signatures panel, a "Verify signatures" action calling the
    endpoint and rendering per-signature badges — Intact ✓/✗ · Issuer trusted ✓/— · Timestamped ✓/—
    (with timestamp time + source). Honest, distinct signals; no green check unless both intact and
    trusted.
  • Existing callers: the port signature change touches the adapter, the Service pass-through
    (service.go:2274), and the one test caller. The provider verify-on-return byte-prefix sanity
    check (service.go:837) does not use Verify and is untouched.

Component 4 — signing-key encryption at rest

  • Cipher: a small esign-local helper (esign/adapters/keycipher.go, ~60 lines) using
    filippo.io/age armored encryption (text in, text out — fits the existing text columns).
    Blob's cipher.go is streaming-shaped and stays untouched.
  • Encoding detection by prefix: -----BEGIN AGE ENCRYPTED FILE----- = ciphertext (decrypt);
    -----BEGIN RSA PRIVATE KEY----- = legacy plaintext (accept). No schema migration — same
    columns, new encoding.
  • Writes: when the master key is configured, ca_certs.key_pem and signing_certs.key_pem
    are always written armored.
  • Boot sweep: one-shot at wire-up when the key is configured — re-encrypt every legacy
    plaintext row (few rows; proactive beats lazy). Idempotent.
  • Key source: EncryptionConfig.Key (BLOB_ENCRYPTION_KEY_FILE). Unset → plaintext
    behaviour unchanged + a boot WARN ("esign signing keys stored unencrypted — set
    BLOB_ENCRYPTION_KEY_FILE"). Set but a row fails to decrypt → fail closed (the affected sign
    operation errors; never guess or fall back to treating ciphertext as PEM).
  • Demo note: the demo deploy already has deploy/secrets/blob_age.key, so the sweep encrypts the
    existing demo rows on first boot after deploy.

Data flow (sign + verify, after this change)

Sign: Service.Sign → pdfsign adapter (leaf + org CA chain, decrypted via keycipher) →
SignData.TSA.URL self-call → POST /api/v1/tsa → RFC3161 token embedded → B-T PDF stored.
Verify: UI "Verify signatures" → GET .../versions/{v}/verify → stored bytes → digitorus verify
against the org-CA pool → distinct signals rendered. Trust: admin downloads root via
GET /api/v1/esign/ca → MDM/GPO → org machines show the signature as trusted.

Error handling

  • TSA route: RFC3161 error replies for bad requests; HTTP 4xx only for transport-level abuse
    (oversized body, wrong content type). Rate-limited.
  • Sign: TSA-configured-but-unreachable → sign fails (fail closed). Key-configured-but-undecryptable
    → sign fails (fail closed).
  • Verify endpoint: never 500s on a malformed/unsigned PDF — returns Signatures: [] or per-signature
    IntegrityOK=false.
  • Boot sweep: a row that fails re-encryption logs ERROR and is skipped (sign attempts against it
    then fail closed); the sweep never blocks boot.

Testing / verification

Repo discipline: never go test (live demo Postgres) — verify via cd go && go build ./... && go vet ./..., curl, and a deployed e2e:
- POST /api/v1/tsa with a real openssl ts -query (or Go-generated) request → valid
timestamp-reply (parse + status granted).
- Internal-sign a document → GET .../versions/{v}/verify reports IntegrityOK=true, IssuerTrusted=true, TimestampPresent=true, TimeSource="tsa".
- Tamper one byte of the signed PDF → verify reports IntegrityOK=false.
- GET /api/v1/esign/ca JSON + ?format=pem|der downloads; fingerprint matches
openssl x509 -fingerprint of the DB row.
- SQL: ca_certs.key_pem / signing_certs.key_pem all start -----BEGIN AGE after boot; signing
still works (round-trip proves decrypt path).
- ESIGN_TSA_URL unset (bare config) → sign still works, verify reports TimeSource="claimed".
- After every deploy: /me enabled_modules == the 5 modules, demo intact; clean up test docs.

Out of scope (documented futures)

  • /DSS + B-LT/B-LTA (the pinned pdfsign has no /DSS emitter — the known blocker if ever
    needed), HSM/KMS KeyProvider, PDF/A, chain-under-a-trusted-anchor (ROADMAP), public
    upload-verify portal, provider (Mekari/Peruri) output changes, CA rotation ceremony.