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:
- The verifier lies.
Signer.Verify(signed) (name string, valid bool, err error)
(esign/app/service.go:88, adapterpdfsign.go:217-223) returns onlys.ValidSignature; the
library setsValidSignature=truevia a barep7.Verify()fallback even when the chain is
untrusted (TrustedIssuer=falseis discarded). Worse,Verifyis effectively dead code — the
UI renders the DB evidence ledger written at sign time and never re-checks the PDF. - Keys are plaintext in Postgres.
ca_certs.key_pemandsigning_certs.key_pem(migration
00011) hold unencrypted PKCS#1 PEM (x509ca.go:132-134encodeKeyPEM). Any DB read (dump,
replica, SQLi) = full CA compromise = forge any employee's signature. - 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-attestationcert (asigning_certsrow owned bysystem: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), gatedrequireModule("esign"), rate-limited with
the auth-route limiter. Request bodyapplication/timestamp-query(DERTimeStampReq) →
timestamp.ParseRequest→ buildtimestamp.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)→200withapplication/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 setsSignData.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 smallESignConfigaddition or alongside the existing esign fields):
default""= no timestamp, today's B-B behaviour (keeps barego rundependency-free);
compose setshttp://127.0.0.1:8080/api/v1/tsa(in-container self-call — the server listens on
:8080). - Failure semantics: fail closed. If
ESIGN_TSA_URLis 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=derstream 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_certsrows), not
system roots —IssuerTrustedthen means "chains to our CA", the meaningful question for the
internal tier.IntegrityOK= ByteRange digest + CMS check. The bare-p7.Verifyfallback maps
toIntegrityOK=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),
elseTimeSource="claimed"with the/Mdate. - Endpoint:
GET /api/v1/documents/{docID}/versions/{version}/verify—requireModule("esign") requireAccess(AccessRead). Loads the stored version bytes (OpenVersionContent) and
re-checks the actual PDF, returning theVerifyResult(emptySignatures= "no signature
found"). This is the re-check the DB evidence ledger never does.- UI: in the
DocumentDetailViewsignatures 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 useVerifyand is untouched.
Component 4 — signing-key encryption at rest
- Cipher: a small esign-local helper (
esign/adapters/keycipher.go, ~60 lines) using
filippo.io/agearmored encryption (text in, text out — fits the existingtextcolumns).
Blob'scipher.gois 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_pemandsigning_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/DSSemitter — the known blocker if ever
needed), HSM/KMSKeyProvider, PDF/A, chain-under-a-trusted-anchor (ROADMAP), public
upload-verify portal, provider (Mekari/Peruri) output changes, CA rotation ceremony.