Internal e-signature: accountable-enough hardening — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make Obscura's free "Internal" PAdES e-signature tier accountable enough for enterprise — self-hosted RFC3161 timestamps (B-T), an honest verifier, signing keys encrypted at rest, and a publishable org trust anchor — while Peruri remains the paid certified tier.
Architecture: Four independent slices layered onto the existing internal/esign context (adapters = crypto + Postgres; app = Service + ports). Signing keys are age-encrypted at the Store boundary (transparent to app). A new in-process RFC3161 responder route lets the digitorus/pdfsign signer self-call for a B-T timestamp. The verifier is rewritten to return distinct, honest signals (integrity / issuer-trusted-against-our-CA / timestamp) and a per-version verify endpoint re-checks the actual stored PDF. The org root cert is exposed for download.
Tech Stack: Go 1.x modular monolith (go/), filippo.io/age (+age/armor) for at-rest encryption (already a dep), github.com/digitorus/timestamp (RFC3161; promote indirect→direct) and github.com/digitorus/pdfsign (PAdES), Postgres via the house *db.DB wrapper, React/Carbon SPA (web/) with an OpenAPI-generated client.
Repo discipline (applies to EVERY task — non-negotiable)
- NEVER run
go test— the test DSN points at the LIVE demo Postgres. Verify Go viacd go && go build ./... && go vet ./...(note:go vetcompiles_test.gofiles, so test files must stay compilable). Rungofmt -l <files>on touched files and fix any output. After edits, scan for smart-quote corruption:grep -nP '[^\x00-\x7F]' <file>(the codebase legitimately uses—em-dashes in comments; a stray’/“in code is the bug). - Web:
cd web && npx tsc --noEmit && npx vite build. After anyapi/openapi.yamlchange:cd web && npm run gen:api, then tsc/build. i18n en+id parity is enforced by tsc — add both. - Deploy ONLY from repo ROOT:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build. Rebuild only what changed (... up -d --build obscuraor... obscura web). - After every deploy: assert
/meenabled_modules == [ai,correspondence,esign,semantic,watermarking]and the demo is intact. Dev-login can race startup — retry a few seconds. - Commit per task locally on
main; do NOT push unless the user asks. Clean up test artifacts (temp PDFs, throwaway docs). - Do NOT touch the provider (Mekari/Peruri) sign path or its verify-on-return sanity check (
esign/app/service.go:837). TheSigner.Signchange is additive (TSA URL);Signer.Verifyis a signature change consumed only by internal code + one test. - These are a plan, not TDD-with-
go test. Each task's "verify" step isgo build/go vet/gofmt(compile-time contract) plus, where a runtime behavior is claimed, a curl/DB check done in Phase 5. Do not invent_test.gofiles that would requirego testto exercise.
File Structure
Phase 1 — key encryption at rest
- Create go/internal/esign/adapters/keycipher.go — age-armored encrypt/decrypt of PEM key blobs + prefix detection (nil cipher = plaintext passthrough).
- Modify go/internal/esign/adapters/pg.go — Store gains a *keyCipher; NewStore(d, cipher); encrypt in InsertCA/InsertSigningCert, decrypt in GetCA/GetSigningCert; add ReEncryptLegacyKeys.
- Modify go/cmd/obscura-server/wire.go — build the cipher from cfg.Encryption.Key, hoist the esign Store to a variable, pass the cipher, run the boot sweep, WARN when unset.
Phase 2 — self-hosted RFC3161 TSA (B-T)
- Modify go/internal/platform/config/config.go — add ESignTSAURL string env:"ESIGN_TSA_URL".
- Modify go/internal/esign/adapters/x509ca.go — add IssueTSACert (critical timeStamping-only EKU).
- Modify go/internal/esign/app/service.go — add CertAuthority.IssueTSACert, a Timestamper port, EnsureTSACert, Timestamp(ctx, reqDER).
- Create go/internal/esign/adapters/tsa.go — Timestamper impl over digitorus/timestamp.
- Modify go/internal/httpapi/handlers_esign.go — TimestampRFC3161 handler.
- Modify go/internal/httpapi/server.go — mount POST /api/v1/tsa; Modify go/internal/httpapi/ratelimit.go — a TSA limiter.
- Modify go/internal/esign/adapters/pdfsign.go — NewPdfSigner(tsaURL) sets SignData.TSA.URL.
- Modify go/cmd/obscura-server/wire.go — pass cfg.ESignTSAURL + the timestamper; Modify go.mod/go.sum (promote digitorus/timestamp); Modify deploy/docker-compose.yml — ESIGN_TSA_URL.
Phase 3 — honest verifier
- Create go/internal/esign/domain/verify.go — VerifyResult + SignatureCheck.
- Modify go/internal/esign/adapters/pdfsign.go — rewrite Verify to (domain.VerifyResult, error), computing issuer-trust against a passed-in CA pool.
- Modify go/internal/esign/app/service.go — Signer.Verify port signature; Service.Verify loads the CA pool; add Repository.ListCACertPEMs.
- Modify go/internal/esign/adapters/pg.go — ListCACertPEMs.
- Modify go/internal/esign/adapters/pg_test.go — update the svc.Verify call (keep go vet green).
- Modify go/internal/httpapi/handlers_esign.go — VerifyDocumentVersion handler.
- Modify go/internal/httpapi/server.go — mount GET /documents/{docID}/versions/{version}/verify.
- Modify api/openapi.yaml — the verify path + schema; regen web client.
Phase 4 — anchor publication + UI verify
- Modify go/internal/esign/app/service.go — CAPublicInfo.
- Modify go/internal/httpapi/handlers_esign.go — GetOrgCA handler; Modify server.go — route.
- Modify api/openapi.yaml — /esign/ca; regen.
- Modify web/src/api/document-detail.ts — useVerifyDocumentVersion.
- Modify web/src/features/documents/DocumentDetailView.tsx — "Verify signatures" action + badges.
- Modify web/src/features/admin/LicensingTab.tsx (+ i18n.ts) — "Organization CA" card; Modify web/src/api for the CA fetch.
- Create docs/INTERNAL_SIGNING.md.
Phase 5 — deploy + e2e.
Phase 1 — Key encryption at rest
Foundation: no behavior change when BLOB_ENCRYPTION_KEY_FILE is unset (plaintext + WARN). When set, keys are stored age-armored and legacy plaintext rows are re-encrypted at boot. Fail-closed on an undecryptable row.
Task 1.1: age key-cipher helper
Files:
- Create: go/internal/esign/adapters/keycipher.go
- [ ] Step 1: Write the file
package adapters
import (
"bytes"
"fmt"
"io"
"strings"
"filippo.io/age"
"filippo.io/age/armor"
)
// keyCipher encrypts/decrypts PEM-encoded private-key blobs at rest with an age
// X25519 identity (the deployment's at-rest master key, shared with blob storage).
// A nil *keyCipher means "no key configured" → plaintext passthrough (dev), so all
// call sites are nil-safe.
//
// Stored form is age ASCII armor (text), so it fits the existing `text` key_pem
// columns unchanged. Encoding is detected by PEM header on read:
// "-----BEGIN AGE ENCRYPTED FILE-----" → ciphertext (decrypt)
// "-----BEGIN RSA PRIVATE KEY-----" → legacy plaintext (accept as-is)
type keyCipher struct {
identity *age.X25519Identity
recipient *age.X25519Recipient
}
const (
ageArmorHeader = "-----BEGIN AGE ENCRYPTED FILE-----"
rsaPEMHeader = "-----BEGIN RSA PRIVATE KEY-----"
)
// newKeyCipher parses the age X25519 identity secret. An empty secret returns
// (nil, nil): plaintext mode. A malformed secret is a hard error (fail at boot,
// don't silently run unencrypted).
func newKeyCipher(secret string) (*keyCipher, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, nil
}
id, err := age.ParseX25519Identity(secret)
if err != nil {
return nil, fmt.Errorf("esign key cipher: parse master key: %w", err)
}
return &keyCipher{identity: id, recipient: id.Recipient()}, nil
}
// encrypt returns the age-armored ciphertext of plain. A nil cipher returns plain
// unchanged (plaintext mode).
func (c *keyCipher) encrypt(plain []byte) ([]byte, error) {
if c == nil {
return plain, nil
}
var buf bytes.Buffer
aw := armor.NewWriter(&buf)
w, err := age.Encrypt(aw, c.recipient)
if err != nil {
return nil, fmt.Errorf("esign key cipher: encrypt init: %w", err)
}
if _, err := w.Write(plain); err != nil {
return nil, fmt.Errorf("esign key cipher: encrypt write: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("esign key cipher: encrypt close: %w", err)
}
if err := aw.Close(); err != nil {
return nil, fmt.Errorf("esign key cipher: armor close: %w", err)
}
return buf.Bytes(), nil
}
// decrypt returns the plaintext PEM for a stored blob. Legacy plaintext (RSA PEM
// header) is returned unchanged. An age blob with no cipher configured, or a
// decrypt failure, is an error (fail closed — never treat ciphertext as a key).
func (c *keyCipher) decrypt(stored []byte) ([]byte, error) {
s := strings.TrimSpace(string(stored))
switch {
case strings.HasPrefix(s, rsaPEMHeader):
return stored, nil // legacy plaintext
case strings.HasPrefix(s, ageArmorHeader):
if c == nil {
return nil, fmt.Errorf("esign key cipher: key blob is encrypted but no master key is configured")
}
ar := armor.NewReader(bytes.NewReader(stored))
r, err := age.Decrypt(ar, c.identity)
if err != nil {
return nil, fmt.Errorf("esign key cipher: decrypt: %w", err)
}
out, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("esign key cipher: decrypt read: %w", err)
}
return out, nil
default:
return nil, fmt.Errorf("esign key cipher: unrecognized key blob encoding")
}
}
// isLegacyPlaintext reports whether a stored blob is unencrypted (used by the boot
// re-encryption sweep to skip already-encrypted rows).
func isLegacyPlaintext(stored []byte) bool {
return strings.HasPrefix(strings.TrimSpace(string(stored)), rsaPEMHeader)
}
- [ ] Step 2: Verify build/vet/fmt
Run: cd go && go build ./... && go vet ./... && gofmt -l internal/esign/adapters/keycipher.go
Expected: no output, exit 0. (keyCipher is unused until Task 1.2 — Go allows unused package-level types, so this compiles.)
- [ ] Step 3: Commit
git add go/internal/esign/adapters/keycipher.go
git commit -m "feat(esign): age key-cipher for signing keys at rest (nil=plaintext)"
Task 1.2: encrypt/decrypt key_pem at the Store boundary
Files:
- Modify: go/internal/esign/adapters/pg.go (Store struct ~27-32; NewStore ~31-32; GetCA ~36-51; InsertCA ~54-61; GetSigningCert ~64-80; InsertSigningCert ~83-91)
- [ ] Step 1: Add the cipher field + constructor param
Replace the Store struct and NewStore (currently type Store struct { db *db.DB } and func NewStore(d *db.DB) *Store { return &Store{db: d} }):
// Store implements app.Repository over Postgres. When keys is non-nil, signing-key
// PEM blobs are encrypted at rest (age armor); a nil keys means plaintext (dev).
type Store struct {
db *db.DB
keys *keyCipher
}
// NewStore constructs the esign repository. keys may be nil (plaintext key storage).
func NewStore(d *db.DB, keys *keyCipher) *Store { return &Store{db: d, keys: keys} }
- [ ] Step 2: Encrypt on CA insert
In InsertCA, replace string(ca.KeyPEM) in the insert args with an encrypted value. Rewrite the function body:
func (s *Store) InsertCA(ctx context.Context, ca domain.CACert) error {
encKey, err := s.keys.encrypt(ca.KeyPEM)
if err != nil {
return err
}
if _, err := s.db.Exec(ctx).Exec(ctx,
`INSERT INTO ca_certs (id, cert_pem, key_pem, created_at) VALUES ($1, $2, $3, $4)`,
ca.ID, string(ca.CertPEM), string(encKey), ca.CreatedAt); err != nil {
return fmt.Errorf("esign insert ca: %w", err)
}
return nil
}
- [ ] Step 3: Decrypt on CA read
In GetCA, after the existing ca.CertPEM = []byte(certPEM) / ca.KeyPEM = []byte(keyPEM) assignment, decrypt the key. Replace those two trailing lines + return ca, nil with:
ca.CertPEM = []byte(certPEM)
decKey, err := s.keys.decrypt([]byte(keyPEM))
if err != nil {
return domain.CACert{}, err
}
ca.KeyPEM = decKey
return ca, nil
- [ ] Step 4: Encrypt on signing-cert insert
Rewrite InsertSigningCert:
func (s *Store) InsertSigningCert(ctx context.Context, c domain.SigningCert) error {
encKey, err := s.keys.encrypt(c.KeyPEM)
if err != nil {
return err
}
if _, err := s.db.Exec(ctx).Exec(ctx,
`INSERT INTO signing_certs (id, user_id, cert_pem, key_pem, serial, not_after, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
c.ID, c.UserID, string(c.CertPEM), string(encKey), c.Serial, c.NotAfter, c.CreatedAt); err != nil {
return fmt.Errorf("esign insert signing cert: %w", err)
}
return nil
}
- [ ] Step 5: Decrypt on signing-cert read
In GetSigningCert, replace the trailing c.CertPEM = []byte(certPEM) / c.KeyPEM = []byte(keyPEM) / return c, nil with:
c.CertPEM = []byte(certPEM)
decKey, err := s.keys.decrypt([]byte(keyPEM))
if err != nil {
return domain.SigningCert{}, err
}
c.KeyPEM = decKey
return c, nil
- [ ] Step 6: Add the boot re-encryption sweep
Append to pg.go (before the var _ app.Repository line):
// ReEncryptLegacyKeys rewrites any plaintext ca_certs/signing_certs key_pem blobs as
// age ciphertext. Idempotent: already-encrypted rows are skipped. A no-op when no
// cipher is configured. A row that fails to re-encrypt is logged by the caller via the
// returned error slice being non-empty is NOT used here — instead we return the count
// converted and the FIRST error, but never abort the whole sweep. Runs at boot.
func (s *Store) ReEncryptLegacyKeys(ctx context.Context) (converted int, err error) {
if s.keys == nil {
return 0, nil
}
// ca_certs
caRows, qerr := s.db.Exec(ctx).Query(ctx, `SELECT id, key_pem FROM ca_certs`)
if qerr != nil {
return converted, fmt.Errorf("esign reencrypt: list ca: %w", qerr)
}
type row struct{ id, keyPEM string }
var cas []row
for caRows.Next() {
var r row
if serr := caRows.Scan(&r.id, &r.keyPEM); serr != nil {
caRows.Close()
return converted, serr
}
cas = append(cas, r)
}
caRows.Close()
for _, r := range cas {
if !isLegacyPlaintext([]byte(r.keyPEM)) {
continue
}
enc, eerr := s.keys.encrypt([]byte(r.keyPEM))
if eerr != nil {
if err == nil {
err = eerr
}
continue
}
if _, uerr := s.db.Exec(ctx).Exec(ctx, `UPDATE ca_certs SET key_pem = $2 WHERE id = $1`, r.id, string(enc)); uerr != nil {
if err == nil {
err = uerr
}
continue
}
converted++
}
// signing_certs
scRows, qerr := s.db.Exec(ctx).Query(ctx, `SELECT id, key_pem FROM signing_certs`)
if qerr != nil {
return converted, fmt.Errorf("esign reencrypt: list signing: %w", qerr)
}
var scs []row
for scRows.Next() {
var r row
if serr := scRows.Scan(&r.id, &r.keyPEM); serr != nil {
scRows.Close()
return converted, serr
}
scs = append(scs, r)
}
scRows.Close()
for _, r := range scs {
if !isLegacyPlaintext([]byte(r.keyPEM)) {
continue
}
enc, eerr := s.keys.encrypt([]byte(r.keyPEM))
if eerr != nil {
if err == nil {
err = eerr
}
continue
}
if _, uerr := s.db.Exec(ctx).Exec(ctx, `UPDATE signing_certs SET key_pem = $2 WHERE id = $1`, r.id, string(enc)); uerr != nil {
if err == nil {
err = uerr
}
continue
}
converted++
}
return converted, err
}
- [ ] Step 7: Verify build/vet/fmt (wire.go call site is still
NewStore(database)— this WILL fail to compile until Task 1.3. Do Task 1.3 before verifying, OR temporarily verify just this package.)
Run: cd go && go build ./internal/esign/... && gofmt -l internal/esign/adapters/pg.go
Expected: package builds; no fmt output. (Full-module build happens after Task 1.3.)
- [ ] Step 8: Commit (bundle with Task 1.3 if you prefer a single compiling commit; otherwise commit the package now.)
git add go/internal/esign/adapters/pg.go
git commit -m "feat(esign): encrypt CA + signing key_pem at rest via Store cipher + boot sweep"
Task 1.3: wire the cipher + boot sweep + WARN
Files:
- Modify: go/cmd/obscura-server/wire.go (esign construction ~245-262)
- [ ] Step 1: Build the cipher, hoist the Store, run the sweep
Replace line esignSvc := esignapp.NewService(esignadapters.NewStore(database), esignadapters.NewX509CA(), esignadapters.NewPdfSigner(), sealer, database) with:
esignKeyCipher, err := esignadapters.NewKeyCipher(cfg.Encryption.Key)
if err != nil {
return nil, fmt.Errorf("esign key cipher: %w", err)
}
if esignKeyCipher == nil {
logger.Warn("esign signing keys are stored UNENCRYPTED — set BLOB_ENCRYPTION_KEY_FILE to encrypt CA + signing keys at rest")
}
esignStore := esignadapters.NewStore(database, esignKeyCipher)
esignSvc := esignapp.NewService(esignStore, esignadapters.NewX509CA(), esignadapters.NewPdfSigner(cfg.ESignTSAURL), sealer, database)
NOTE:
NewKeyCipher(exported) — rename the Task 1.1 constructor fromnewKeyCiphertoNewKeyCipher(exported) since wire.go (a different package) calls it. Updatekeycipher.goaccordingly.NewPdfSigner(cfg.ESignTSAURL)depends on Task 2.4 (the signature change) andcfg.ESignTSAURLon Task 2.1 — if doing Phase 1 alone, temporarily callNewPdfSigner("")andNewStore(esignStore...); the Phase 2 tasks finalize it. Prefer: do Task 2.1 + 2.4'sNewPdfSignersignature change together so the module compiles once.
- [ ] Step 2: Run the boot sweep — after the existing
esignSvc.SetAppBaseURL(cfg.AppBaseURL)block and the provider WARN, add:
if n, rerr := esignStore.ReEncryptLegacyKeys(ctx); rerr != nil {
logger.Error("esign: re-encrypting legacy signing keys failed for some rows (they will fail closed on use)", "converted", n, "err", rerr)
} else if n > 0 {
logger.Info("esign: encrypted legacy signing keys at rest", "converted", n)
}
Confirm a
ctxis in scope at this point inwire.go(the composition root threads one through). If the local is named differently (e.g.bootCtx), use that. If none exists, usecontext.Background().
-
[ ] Step 3: Verify —
cd go && go build ./... && go vet ./... && gofmt -l cmd/obscura-server/wire.go. Expected: clean (assumingNewPdfSigner("")placeholder or Task 2.1/2.4 done). -
[ ] Step 4: Rename check — ensure
keycipher.goexportsNewKeyCipherand internal helpers (encrypt/decrypt/isLegacyPlaintext) stay lowercase (same package aspg.go). Re-run build. -
[ ] Step 5: Commit
git add go/cmd/obscura-server/wire.go go/internal/esign/adapters/keycipher.go
git commit -m "feat(esign): wire key cipher + boot re-encryption sweep + unset-key WARN"
Phase 2 — self-hosted RFC3161 TSA (B-T)
Deployable after Phase 1. When ESIGN_TSA_URL is empty, signing is unchanged (B-B, TimeSource=claimed). When set (compose → self-call), every internal/attestation signature carries an RFC3161 timestamp (B-T); a TSA failure fails the sign (fail closed).
Task 2.1: config knob + go.mod promotion
Files:
- Modify: go/internal/platform/config/config.go (Config struct ~26-28)
- Modify: go/go.mod
- [ ] Step 1: Add the field — after the
ESignProviderfield (line ~26) add:
// ESignTSAURL, when set, is the RFC3161 TSA the internal PAdES signer calls to embed a
// signature timestamp (PAdES B-T). Empty = no timestamp (B-B). In the compose stack this
// points at our own in-process responder (http://127.0.0.1:8080/api/v1/tsa).
ESignTSAURL string `env:"ESIGN_TSA_URL"`
-
[ ] Step 2: Promote the timestamp dep —
cd go && go get github.com/digitorus/timestamp@v0.0.0-20231217203849-220c5c2851b7 && go mod tidy. This movesdigitorus/timestampfrom// indirectto a direct require. Verify it's still the same pinned version. -
[ ] Step 3: Verify —
cd go && go build ./... && go vet ./.... Expected: clean. -
[ ] Step 4: Commit
git add go/internal/platform/config/config.go go/go.mod go/go.sum
git commit -m "feat(config): ESIGN_TSA_URL + promote digitorus/timestamp to direct dep"
Task 2.2: TSA certificate issuance (critical timeStamping EKU)
Files:
- Modify: go/internal/esign/adapters/x509ca.go (add method + OID consts near top)
- [ ] Step 1: Add OID consts + imports — add
"encoding/asn1"to the import block. BelowNewX509CAadd:
// oidExtKeyUsage is the X.509 Extended Key Usage extension (2.5.29.37); oidTimeStamping
// is id-kp-timeStamping (1.3.6.1.5.5.7.3.8). RFC3161 §2.3 requires a TSA certificate to
// carry timeStamping as its ONLY EKU, marked CRITICAL — which crypto/x509's ExtKeyUsage
// field cannot express (it emits a non-critical EKU), so we add the extension by hand.
var (
oidExtKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37}
oidTimeStamping = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 8}
)
// IssueTSACert issues a timestamping certificate from the in-house CA: a
// DigitalSignature key-usage leaf whose ONLY, CRITICAL EKU is id-kp-timeStamping.
func (c *X509CA) IssueTSACert(caCertPEM, caKeyPEM []byte, commonName string) (certPEM, keyPEM []byte, serial string, notAfter time.Time, err error) {
caCert, err := decodeCertPEM(caCertPEM)
if err != nil {
return nil, nil, "", time.Time{}, err
}
caKey, err := decodeKeyPEM(caKeyPEM)
if err != nil {
return nil, nil, "", time.Time{}, err
}
tsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, "", time.Time{}, fmt.Errorf("esign generate tsa key: %w", err)
}
serialNum, err := randomSerial()
if err != nil {
return nil, nil, "", time.Time{}, err
}
ekuValue, err := asn1.Marshal([]asn1.ObjectIdentifier{oidTimeStamping})
if err != nil {
return nil, nil, "", time.Time{}, fmt.Errorf("esign marshal tsa eku: %w", err)
}
now := time.Now()
notAfter = now.Add(c.signingValidity)
tsaTmpl := &x509.Certificate{
SerialNumber: serialNum,
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"Obscura"},
},
NotBefore: now.Add(-time.Hour),
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
// EKU set via a CRITICAL ExtraExtension (not the ExtKeyUsage field) so the
// timeStamping usage is the only EKU and is marked critical, per RFC3161.
ExtraExtensions: []pkix.Extension{{Id: oidExtKeyUsage, Critical: true, Value: ekuValue}},
}
der, err := x509.CreateCertificate(rand.Reader, tsaTmpl, caCert, &tsaKey.PublicKey, caKey)
if err != nil {
return nil, nil, "", time.Time{}, fmt.Errorf("esign create tsa cert: %w", err)
}
return encodeCertPEM(der), encodeKeyPEM(tsaKey), domain.FormatSerial(serialNum.Bytes()), notAfter, nil
}
-
[ ] Step 2: Verify —
cd go && go build ./internal/esign/... && gofmt -l internal/esign/adapters/x509ca.go. (Full build fails until the port method is added in Task 2.3 — that's expected; verify the package only, then bundle the commit with 2.3.) -
[ ] Step 3: Commit (bundle with 2.3)
Task 2.3: Timestamper port + Service.Timestamp + adapter
Files:
- Create: go/internal/esign/adapters/tsa.go
- Modify: go/internal/esign/app/service.go (CertAuthority port ~77-80; add Timestamper port; Service struct ~403-406 + NewService ~439-441; add EnsureTSACert + Timestamp methods; tsaPrincipalID const)
- [ ] Step 1: Extend the
CertAuthorityport — add to the interface (afterIssueSigningCert):
IssueTSACert(caCertPEM, caKeyPEM []byte, commonName string) (certPEM, keyPEM []byte, serial string, notAfter time.Time, err error)
- [ ] Step 2: Add the
Timestamperport — after theSignerinterface add:
// Timestamper answers an RFC3161 timestamp request (DER TimeStampReq → DER
// TimeStampResp), signing the token with the given TSA cert+key. It is the crypto
// boundary for the self-hosted TSA — the app layer never touches digitorus/timestamp.
type Timestamper interface {
Respond(tsaCertPEM, tsaKeyPEM, reqDER []byte, now time.Time) (respDER []byte, err error)
}
- [ ] Step 3: Add the timestamper to
Service— add fieldtimestamper Timestamperto theServicestruct, a constructor param, and store it. ChangeNewService:
func NewService(repo Repository, ca CertAuthority, signer Signer, sealer ExternalSealer, timestamper Timestamper, uow kernel.UnitOfWork) *Service {
return &Service{repo: repo, ca: ca, signer: signer, sealer: sealer, timestamper: timestamper, uow: uow, clock: kernel.SystemClock()}
}
Add timestamper Timestamper to the Service struct definition (near signer Signer).
- [ ] Step 4: Add
EnsureTSACert+Timestamp— aftersignAttestation(~660) add:
// tsaPrincipalID is the reserved system identity holding the in-house TSA certificate.
const tsaPrincipalID = "system:tsa"
// EnsureTSACert returns the in-house TSA signing cert, issuing one from the CA when
// absent/expired. Mirrors EnsureSigningCert but issues a timeStamping-EKU cert.
func (s *Service) EnsureTSACert(ctx context.Context) (domain.SigningCert, error) {
ca, err := s.EnsureCA(ctx)
if err != nil {
return domain.SigningCert{}, err
}
var out domain.SigningCert
err = s.uow.Do(ctx, func(ctx context.Context) error {
if cert, gerr := s.repo.GetSigningCert(ctx, tsaPrincipalID); gerr == nil {
if !cert.IsExpired(s.clock.Now()) {
out = cert
return nil
}
} else if !isNotFound(gerr) {
return gerr
}
certPEM, keyPEM, serial, notAfter, ierr := s.ca.IssueTSACert(ca.CertPEM, ca.KeyPEM, "Obscura TSA")
if ierr != nil {
return ierr
}
cert := domain.SigningCert{
ID: kernel.NewID(),
UserID: tsaPrincipalID,
CertPEM: certPEM,
KeyPEM: keyPEM,
Serial: serial,
NotAfter: notAfter,
CreatedAt: s.clock.Now(),
}
if ierr := s.repo.InsertSigningCert(ctx, cert); ierr != nil {
return ierr
}
out = cert
return nil
})
if err != nil {
return domain.SigningCert{}, err
}
return out, nil
}
// Timestamp answers an RFC3161 request with a signed timestamp token from the in-house
// TSA cert. reqDER is the DER TimeStampReq body; the return is the DER TimeStampResp.
func (s *Service) Timestamp(ctx context.Context, reqDER []byte) ([]byte, error) {
if s.timestamper == nil {
return nil, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.tsa.unavailable", Message: "timestamp authority not configured"}
}
tsa, err := s.EnsureTSACert(ctx)
if err != nil {
return nil, err
}
return s.timestamper.Respond(tsa.CertPEM, tsa.KeyPEM, reqDER, s.clock.Now())
}
- [ ] Step 5: Write the adapter —
go/internal/esign/adapters/tsa.go:
package adapters
import (
"bytes"
"crypto"
"crypto/rand"
"fmt"
"math/big"
"time"
"github.com/digitorus/timestamp"
"github.com/Virtue-Digital-Indonesia/obscura/internal/esign/app"
)
// TSAResponder implements app.Timestamper using digitorus/timestamp. It parses an
// RFC3161 request, builds a granted timestamp for the current time with a fresh random
// serial, and signs it with the in-house TSA cert+key.
type TSAResponder struct{}
// NewTSAResponder constructs the RFC3161 responder adapter.
func NewTSAResponder() *TSAResponder { return &TSAResponder{} }
// Respond parses reqDER (a TimeStampReq), and returns a DER-encoded TimeStampResp
// signed by the TSA cert. A malformed request yields a valid RFC3161 rejection reply
// (not a Go error) so the HTTP layer always returns a well-formed timestamp-reply.
func (t *TSAResponder) Respond(tsaCertPEM, tsaKeyPEM, reqDER []byte, now time.Time) ([]byte, error) {
req, err := timestamp.ParseRequest(reqDER)
if err != nil {
return timestamp.CreateErrorResponse(timestamp.Rejection, timestamp.BadDataFormat)
}
tsaCert, err := decodeCertPEM(tsaCertPEM)
if err != nil {
return nil, err
}
tsaKey, err := decodeKeyPEM(tsaKeyPEM)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("esign tsa serial: %w", err)
}
ts := timestamp.Timestamp{
HashAlgorithm: req.HashAlgorithm,
HashedMessage: req.HashedMessage,
Time: now.UTC(),
Accuracy: time.Second,
SerialNumber: serial,
Certificates: []*x509Cert{}, // populated by AddTSACertificate
AddTSACertificate: true,
Nonce: req.Nonce, // echo the client nonce when present (nil otherwise)
}
respDER, err := ts.CreateResponseWithOpts(tsaCert, tsaKey, crypto.SHA256)
if err != nil {
return nil, fmt.Errorf("esign tsa create response: %w", err)
}
return respDER, nil
}
var _ app.Timestamper = (*TSAResponder)(nil)
var _ = bytes.MinRead // keep bytes imported if unused elsewhere; remove if gofmt/vet flags it
IMPLEMENTER NOTE:
x509Certabove is a placeholder to avoid importingcrypto/x509twice — DELETE theCertificatesline entirely (the field defaults to nil andAddTSACertificate: trueis what includes the cert). Final struct literal should omitCertificates. Also delete thebytesimport + thebytes.MinReadguard line if unused. Keep only imports actually referenced (crypto,crypto/rand,fmt,math/big,time,digitorus/timestamp, the app package). Rungofmt+go vetto confirm imports are clean.
-
[ ] Step 6: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/esign/adapters/tsa.go internal/esign/adapters/x509ca.go internal/esign/app/service.go. Fix theNewServicecall site inwire.goto pass the responder (Task 2.5). Expected after 2.5: clean. -
[ ] Step 7: Commit (bundle 2.2+2.3)
git add go/internal/esign/adapters/x509ca.go go/internal/esign/adapters/tsa.go go/internal/esign/app/service.go
git commit -m "feat(esign): in-house TSA cert (critical timeStamping EKU) + Timestamper port + Service.Timestamp"
Task 2.4: HTTP TSA route + rate limiter
Files:
- Modify: go/internal/httpapi/ratelimit.go (consts ~57-59; limiter vars ~66-67)
- Modify: go/internal/httpapi/handlers_esign.go (add handler)
- Modify: go/internal/httpapi/server.go (public block ~237-240)
-
[ ] Step 1: Add a TSA limiter — in
ratelimit.go, add constapiRateTSAMax = 600 // RFC3161 timestamp requests per IP per window; loopback (our own signer) dominatesnext to the other maxes, and a limitertsaLimiter = newRateLimiter(apiRateWindow)next toauthLimiter. -
[ ] Step 2: Add the handler — in
handlers_esign.go:
// maxTSARequestBytes bounds an RFC3161 request body (real requests are <1 KiB).
const maxTSARequestBytes = 10 << 10
// TimestampRFC3161 is the self-hosted RFC3161 Time-Stamp Authority. It reads a DER
// TimeStampReq (application/timestamp-query) and returns a DER TimeStampResp
// (application/timestamp-reply). Unauthenticated (RFC3161 clients don't authenticate);
// gated on the esign module + rate-limited. The internal PAdES signer self-calls it.
func (s *Server) TimestampRFC3161(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxTSARequestBytes+1))
if err != nil || len(body) == 0 || len(body) > maxTSARequestBytes {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.tsa.bad_request", Message: "invalid timestamp request"})
return
}
resp, err := s.esign.Timestamp(r.Context(), body)
if err != nil {
writeProblem(w, err)
return
}
w.Header().Set("Content-Type", "application/timestamp-reply")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(resp)
}
Confirm
ioandnet/httpare imported inhandlers_esign.go(they are, given existing handlers).s.esignis the esign Service field onServer— confirm the field name by grepping existing handlers (e.g.s.esign.SignPDF/s.esign.Verify); use whatever the existing name is.
- [ ] Step 3: Mount the route — in
server.go, in the PUBLIC block (near the/public/sign/*routes ~237-240), add:
// Public: self-hosted RFC3161 Time-Stamp Authority (no session; RFC3161 clients don't
// authenticate). Gated on the esign module + its own IP rate limit. The internal PAdES
// signer self-calls this to embed a B-T signature timestamp.
tsaRL := s.rateLimit(tsaLimiter, ipKeyFn, apiRateTSAMax)
r.With(tsaRL, s.requireModule("esign")).Post("/tsa", s.TimestampRFC3161)
-
[ ] Step 4: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/httpapi/ratelimit.go internal/httpapi/handlers_esign.go internal/httpapi/server.go. Expected: clean. -
[ ] Step 5: Commit
git add go/internal/httpapi/ratelimit.go go/internal/httpapi/handlers_esign.go go/internal/httpapi/server.go
git commit -m "feat(esign): POST /api/v1/tsa RFC3161 responder (module-gated, rate-limited)"
Task 2.5: signer TSA wiring + compose
Files:
- Modify: go/internal/esign/adapters/pdfsign.go (PdfSigner struct ~24; NewPdfSigner ~27; buildData ~69-113)
- Modify: go/cmd/obscura-server/wire.go (NewService call from Task 1.3/2.3)
- Modify: deploy/docker-compose.yml (obscura env ~126)
- [ ] Step 1: Give the signer a TSA URL — change the struct + constructor:
// PdfSigner implements app.Signer using digitorus/pdfsign. When tsaURL is non-empty,
// Sign requests an RFC3161 timestamp from it (PAdES B-T); empty = no timestamp (B-B).
type PdfSigner struct {
tsaURL string
}
// NewPdfSigner constructs the PAdES signer adapter. tsaURL "" disables timestamping.
func NewPdfSigner(tsaURL string) *PdfSigner { return &PdfSigner{tsaURL: tsaURL} }
- [ ] Step 2: Set the TSA on the sign data — inside
buildData, after thesd := sign.SignData{...}literal (right before theif visible {block), add:
if p.tsaURL != "" {
sd.TSA = sign.TSA{URL: p.tsaURL}
}
buildDatais a closure insideSign, which has receiverp *PdfSigner, sop.tsaURLis in scope. A TSA fetch failure makessign.SignFilereturn an error →Signreturns it → the sign fails (fail closed), which is the intended behavior.
- [ ] Step 3: Finalize wire.go — ensure the esign construction (Task 1.3) reads:
esignSvc := esignapp.NewService(esignStore, esignadapters.NewX509CA(), esignadapters.NewPdfSigner(cfg.ESignTSAURL), sealer, esignadapters.NewTSAResponder(), database)
(The Timestamper is the new 5th param before uow.)
- [ ] Step 4: Compose env — in
deploy/docker-compose.yml, in the obscura service env nearESIGN_PROVIDER(~126), add:
ESIGN_TSA_URL: "${ESIGN_TSA_URL:-http://127.0.0.1:8080/api/v1/tsa}"
-
[ ] Step 5: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/esign/adapters/pdfsign.go cmd/obscura-server/wire.go. Expected: clean. -
[ ] Step 6: Commit
git add go/internal/esign/adapters/pdfsign.go go/cmd/obscura-server/wire.go deploy/docker-compose.yml
git commit -m "feat(esign): internal signer requests B-T timestamp from self-hosted TSA (compose self-call)"
Phase 3 — Honest verifier
Replaces the always-true Verify bool with distinct signals and adds a per-version verify endpoint that re-checks the stored PDF.
Task 3.1: verify result domain types
Files:
- Create: go/internal/esign/domain/verify.go
- [ ] Step 1: Write the file
package domain
import "time"
// VerifyResult is the honest outcome of re-checking a signed PDF: one SignatureCheck
// per embedded signature. An empty Signatures slice means "no signature found".
type VerifyResult struct {
Signatures []SignatureCheck
}
// SignatureCheck reports independent, non-collapsed signals for one signature:
// - IntegrityOK: the CMS signature is cryptographically intact over its ByteRange.
// - IssuerTrusted: the signer cert chains to one of OUR in-house CA certs (not
// "any embedded cert" — the library's own TrustedIssuer trusts self-embedded roots).
// - TimestampPresent/TimestampTime/TimeSource: an RFC3161 signature timestamp, if any
// ("tsa" = from an embedded RFC3161 token; "claimed" = the signer's unverified /M date).
type SignatureCheck struct {
SignerName string
SubjectCN string
IntegrityOK bool
IssuerTrusted bool
TimestampPresent bool
TimestampTime *time.Time
TimeSource string // "tsa" | "claimed"
ClaimedTime *time.Time
CertNotAfter time.Time
}
-
[ ] Step 2: Verify —
cd go && go build ./internal/esign/... && gofmt -l internal/esign/domain/verify.go. Expected: clean. -
[ ] Step 3: Commit
git add go/internal/esign/domain/verify.go
git commit -m "feat(esign): VerifyResult + SignatureCheck (honest, distinct verify signals)"
Task 3.2: repository CA-pool accessor + Signer.Verify rewrite
Files:
- Modify: go/internal/esign/app/service.go (Repository port — add ListCACertPEMs; Signer port ~86-89)
- Modify: go/internal/esign/adapters/pg.go (add ListCACertPEMs)
- Modify: go/internal/esign/adapters/pdfsign.go (rewrite Verify ~215-224)
- [ ] Step 1: Add
ListCACertPEMsto theRepositoryport — add to the interface (nearGetCA):
// ListCACertPEMs returns every in-house CA certificate (PEM), for building the
// verifier's trust pool. Usually one row.
ListCACertPEMs(ctx context.Context) ([][]byte, error)
- [ ] Step 2: Implement it in the Store — add to
pg.go:
// ListCACertPEMs returns all in-house CA certificate PEMs (the verifier trust pool).
func (s *Store) ListCACertPEMs(ctx context.Context) ([][]byte, error) {
rows, err := s.db.Exec(ctx).Query(ctx, `SELECT cert_pem FROM ca_certs ORDER BY created_at ASC`)
if err != nil {
return nil, fmt.Errorf("esign list ca certs: %w", err)
}
defer rows.Close()
var out [][]byte
for rows.Next() {
var pemStr string
if err := rows.Scan(&pemStr); err != nil {
return nil, fmt.Errorf("esign scan ca cert: %w", err)
}
out = append(out, []byte(pemStr))
}
return out, rows.Err()
}
- [ ] Step 3: Change the
Signerport — replace theVerifyline in theSignerinterface:
// Verify re-checks a signed PDF and reports honest, distinct signals per signature.
// caCertPEMs are the trusted in-house CA roots (issuer-trust is computed against
// these, NOT the certs embedded in the PDF). A malformed/unsigned PDF yields an empty
// VerifyResult (not an error).
Verify(signed []byte, caCertPEMs [][]byte) (domain.VerifyResult, error)
Add the domain import to service.go if not present (it is — SignInfo/records use it).
- [ ] Step 4: Rewrite the adapter
Verify— replacepdfsign.go'sVerify(~212-224):
// Verify re-checks the signed PDF and returns honest per-signature signals. Integrity
// comes from the library's cryptographic check; issuer-trust is computed HERE against
// the caller-supplied in-house CA pool (the library's own TrustedIssuer trusts certs
// embedded in the PDF, which for a self-issued chain is meaningless). Timestamp signals
// come from the library's parsed RFC3161 token. A verification negative is not an error.
func (p *PdfSigner) Verify(signed []byte, caCertPEMs [][]byte) (domain.VerifyResult, error) {
roots := x509.NewCertPool()
for _, pemBytes := range caCertPEMs {
if c, derr := decodeCertPEM(pemBytes); derr == nil {
roots.AddCert(c)
}
}
resp, err := verify.Verify(bytes.NewReader(signed), int64(len(signed)))
if err != nil || resp == nil {
// Unparseable / unsigned PDF → no signatures (not an error to the caller).
return domain.VerifyResult{}, nil
}
var out domain.VerifyResult
for i := range resp.Signers {
sg := resp.Signers[i]
chk := domain.SignatureCheck{
SignerName: sg.Name,
IntegrityOK: sg.ValidSignature,
}
// Locate the leaf (signer) cert + collect the rest as intermediates.
var leaf *x509.Certificate
inter := x509.NewCertPool()
for j := range sg.Certificates {
c := sg.Certificates[j].Certificate
if c == nil {
continue
}
if leaf == nil {
leaf = c
} else {
inter.AddCert(c)
}
}
if leaf != nil {
chk.SubjectCN = leaf.Subject.CommonName
chk.CertNotAfter = leaf.NotAfter
if _, verr := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: inter,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); verr == nil {
chk.IssuerTrusted = true
}
}
// Timestamp: prefer an embedded RFC3161 token; else the signer's claimed /M time.
if sg.TimeStamp != nil && !sg.TimeStamp.Time.IsZero() {
t := sg.TimeStamp.Time
chk.TimestampPresent = true
chk.TimestampTime = &t
chk.TimeSource = "tsa"
} else {
chk.TimeSource = "claimed"
if sg.SignatureTime != nil {
chk.ClaimedTime = sg.SignatureTime
}
}
out.Signatures = append(out.Signatures, chk)
}
return out, nil
}
The
verify.Signerstruct exposesName,ValidSignature,Certificates []Certificate(each with aCertificate *x509.Certificate),TimeStamp *timestamp.Timestamp, andSignatureTime *time.Time— confirmed in the pinned lib'sverify/types.go. The first cert inSigners[i].Certificatesis the signer leaf. Update imports:pdfsign.goneedsdomain(.../internal/esign/domain);crypto/osetc. already present;crypto/x509already imported. Remove any now-unused imports andgofmt.
-
[ ] Step 5: Verify —
cd go && go build ./internal/esign/... && gofmt -l internal/esign/adapters/pdfsign.go internal/esign/adapters/pg.go. Full-module build fails until Task 3.3 (Service.Verify + test caller) — expected. -
[ ] Step 6: Commit (bundle with 3.3)
Task 3.3: Service.Verify + fix the test caller
Files:
- Modify: go/internal/esign/app/service.go (Verify ~2274-2276)
- Modify: go/internal/esign/adapters/pg_test.go (svc.Verify call ~133)
- [ ] Step 1: Rewrite
Service.Verify
// Verify re-checks a signed PDF against the in-house CA trust pool and returns honest,
// per-signature signals. A verification negative is an empty result, not an error.
func (s *Service) Verify(signed []byte) (domain.VerifyResult, error) {
caCertPEMs, err := s.repo.ListCACertPEMs(ctx)
if err != nil {
return domain.VerifyResult{}, err
}
return s.signer.Verify(signed, caCertPEMs)
}
Service.Verifycurrently has noctxparam. Change its signature toVerify(ctx context.Context, signed []byte) (domain.VerifyResult, error)and usectxforListCACertPEMs. Update the doc comment above it. (The only production caller is the new handler in Task 3.4, which hasr.Context().)
- [ ] Step 2: Fix the test caller —
pg_test.go:~133currently:
name, valid, err := svc.Verify(signed)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !valid {
t.Fatalf("signature did not validate (signer=%q)", name)
}
Replace with (compile-only; this file is never run — go test is forbidden — but must compile for go vet):
res, err := svc.Verify(context.Background(), signed)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if len(res.Signatures) == 0 || !res.Signatures[0].IntegrityOK {
t.Fatalf("signature did not validate")
}
Ensure
contextis imported inpg_test.go(add it if missing). Do NOT rungo test;go vet ./...compiles it.
-
[ ] Step 3: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/esign/app/service.go internal/esign/adapters/pg_test.go. Expected: clean. -
[ ] Step 4: Commit (bundle 3.2+3.3)
git add go/internal/esign/app/service.go go/internal/esign/adapters/pdfsign.go go/internal/esign/adapters/pg.go go/internal/esign/adapters/pg_test.go
git commit -m "feat(esign): honest verifier — issuer-trust vs in-house CA pool, distinct signals"
Task 3.4: verify endpoint
Files:
- Modify: go/internal/httpapi/handlers_esign.go (add handler; model the response DTO on the existing signatures list handler)
- Modify: go/internal/httpapi/server.go (per-doc routes ~348-357)
- [ ] Step 1: Add the handler — in
handlers_esign.go:
// verifySignatureDTO is one signature's honest verification signals (JSON).
type verifySignatureDTO struct {
SignerName string `json:"signer_name"`
SubjectCN string `json:"subject_cn"`
IntegrityOK bool `json:"integrity_ok"`
IssuerTrusted bool `json:"issuer_trusted"`
TimestampPresent bool `json:"timestamp_present"`
TimestampTime *string `json:"timestamp_time,omitempty"`
TimeSource string `json:"time_source"`
ClaimedTime *string `json:"claimed_time,omitempty"`
CertNotAfter string `json:"cert_not_after"`
}
// VerifyDocumentVersion re-checks the stored bytes of a document version's PDF and
// returns honest per-signature signals. Requires the esign module + read content access.
func (s *Server) VerifyDocumentVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "docID")
version, err := strconv.Atoi(chi.URLParam(r, "version"))
if err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.bad_version", Message: "version must be an integer"})
return
}
rc, err := s.dms.OpenVersionContent(r.Context(), docID, version)
if err != nil {
writeProblem(w, err)
return
}
pdf, err := io.ReadAll(rc)
rc.Close()
if err != nil {
writeProblem(w, err)
return
}
res, err := s.esign.Verify(r.Context(), pdf)
if err != nil {
writeProblem(w, err)
return
}
out := struct {
Signatures []verifySignatureDTO `json:"signatures"`
}{Signatures: make([]verifySignatureDTO, 0, len(res.Signatures))}
for _, c := range res.Signatures {
dto := verifySignatureDTO{
SignerName: c.SignerName, SubjectCN: c.SubjectCN,
IntegrityOK: c.IntegrityOK, IssuerTrusted: c.IssuerTrusted,
TimestampPresent: c.TimestampPresent, TimeSource: c.TimeSource,
CertNotAfter: c.CertNotAfter.UTC().Format(time.RFC3339),
}
if c.TimestampTime != nil {
ts := c.TimestampTime.UTC().Format(time.RFC3339)
dto.TimestampTime = &ts
}
if c.ClaimedTime != nil {
ct := c.ClaimedTime.UTC().Format(time.RFC3339)
dto.ClaimedTime = &ct
}
out.Signatures = append(out.Signatures, dto)
}
writeJSON(w, http.StatusOK, out)
}
Confirm
strconv,time,ioimported;s.dms.OpenVersionContentis the same callSignDocumentVersionuses (verified at handlers_esign.go:354).
- [ ] Step 2: Mount the route — in
server.goper-document block (next to the/versions/{version}/...routes ~350-357):
r.With(s.requireModule("esign"), s.requireAccess(dmsdomain.AccessRead)).Get("/versions/{version}/verify", s.VerifyDocumentVersion)
-
[ ] Step 3: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/httpapi/handlers_esign.go internal/httpapi/server.go. Expected: clean. -
[ ] Step 4: Commit
git add go/internal/httpapi/handlers_esign.go go/internal/httpapi/server.go
git commit -m "feat(esign): GET /documents/{id}/versions/{v}/verify re-checks stored PDF"
Task 3.5: OpenAPI for the verify endpoint + regen client
Files:
- Modify: api/openapi.yaml (add the path + a VersionVerifyResult schema, mirroring the listDocumentSignatures path ~1450 + the SignatureList schema ~6775)
- [ ] Step 1: Add the path — after the
/api/v1/documents/{docID}/signaturespath block:
/api/v1/documents/{docID}/versions/{version}/verify:
get:
operationId: verifyDocumentVersion
summary: Verify a document version's e-signatures
description: >-
Re-checks the stored PDF bytes of a document version and reports honest,
distinct per-signature signals (cryptographic integrity, whether the signer
chains to the in-house CA, and any RFC3161 timestamp). Requires the esign
module and read content access. An unsigned/malformed PDF returns an empty list.
tags: [esign]
parameters:
- $ref: '#/components/parameters/DocID'
- name: version
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Per-signature verification signals.
content:
application/json:
schema:
$ref: '#/components/schemas/VersionVerifyResult'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
'404':
$ref: '#/components/responses/Problem'
- [ ] Step 2: Add the schema — in
components.schemas(nearSignatureList):
VersionVerifyResult:
type: object
required: [signatures]
properties:
signatures:
type: array
items:
type: object
required: [signer_name, subject_cn, integrity_ok, issuer_trusted, timestamp_present, time_source, cert_not_after]
properties:
signer_name: { type: string }
subject_cn: { type: string }
integrity_ok: { type: boolean }
issuer_trusted: { type: boolean }
timestamp_present: { type: boolean }
timestamp_time: { type: string, format: date-time }
time_source: { type: string, enum: [tsa, claimed] }
claimed_time: { type: string, format: date-time }
cert_not_after: { type: string, format: date-time }
-
[ ] Step 3: Regen + verify —
cd web && npm run gen:api && npx tsc --noEmit && npx vite build. Expected: client regenerates, tsc clean. -
[ ] Step 4: Commit
git add api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(api): openapi + client for verifyDocumentVersion"
Phase 4 — anchor publication + UI verify badges
Task 4.1: CA public-info service method + endpoint
Files:
- Modify: go/internal/esign/app/service.go (add CAPublicInfo)
- Modify: go/internal/httpapi/handlers_esign.go (add GetOrgCA)
- Modify: go/internal/httpapi/server.go (add route)
- [ ] Step 1: Service method — add to
service.go:
// CAInfo is the public description of the in-house CA (no private key).
type CAInfo struct {
PEM string
DER []byte
SHA256Fingerprint string // colon-separated uppercase hex of the DER
Subject string
NotBefore time.Time
NotAfter time.Time
}
// CAPublicInfo returns the in-house CA's public certificate + metadata for
// distribution (trust-anchor publication). It ensures a CA exists first.
func (s *Service) CAPublicInfo(ctx context.Context) (CAInfo, error) {
ca, err := s.EnsureCA(ctx)
if err != nil {
return CAInfo{}, err
}
block, _ := pem.Decode(ca.CertPEM)
if block == nil {
return CAInfo{}, &kernel.Error{Kind: kernel.ErrInternal, Code: "esign.ca.decode", Message: "ca cert not decodable"}
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return CAInfo{}, fmt.Errorf("esign parse ca cert: %w", err)
}
sum := sha256.Sum256(block.Bytes)
hexParts := make([]string, len(sum))
for i, b := range sum {
hexParts[i] = fmt.Sprintf("%02X", b)
}
return CAInfo{
PEM: string(ca.CertPEM),
DER: block.Bytes,
SHA256Fingerprint: strings.Join(hexParts, ":"),
Subject: cert.Subject.String(),
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
}, nil
}
Add imports to
service.go:crypto/x509,encoding/pem,strings(if not already present —crypto/sha256is already used bysha256Hex). Confirmkernel.ErrInternalexists (grepkernel.ErrInternal); if the codebase uses a different internal-error kind, use that.
- [ ] Step 2: Handler — in
handlers_esign.go:
// GetOrgCA publishes the in-house CA certificate (trust anchor). Default is JSON with
// the PEM + SHA-256 fingerprint + subject/validity; ?format=pem or ?format=der streams a
// downloadable cert for MDM/GPO import. Requires the esign module + document.read.
func (s *Server) GetOrgCA(w http.ResponseWriter, r *http.Request) {
info, err := s.esign.CAPublicInfo(r.Context())
if err != nil {
writeProblem(w, err)
return
}
switch r.URL.Query().Get("format") {
case "pem":
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="obscura-ca.pem"`)
_, _ = w.Write([]byte(info.PEM))
case "der":
w.Header().Set("Content-Type", "application/pkix-cert")
w.Header().Set("Content-Disposition", `attachment; filename="obscura-ca.cer"`)
_, _ = w.Write(info.DER)
default:
writeJSON(w, http.StatusOK, map[string]any{
"pem": info.PEM,
"sha256_fingerprint": info.SHA256Fingerprint,
"subject": info.Subject,
"not_before": info.NotBefore.UTC().Format(time.RFC3339),
"not_after": info.NotAfter.UTC().Format(time.RFC3339),
})
}
}
- [ ] Step 3: Route — in
server.go, in the protected block near/me/signatures(~265), add:
r.With(s.requireModule("esign"), s.requirePerm("document.read")).Get("/esign/ca", s.GetOrgCA)
-
[ ] Step 4: Verify —
cd go && go build ./... && go vet ./... && gofmt -l internal/esign/app/service.go internal/httpapi/handlers_esign.go internal/httpapi/server.go. Expected: clean. -
[ ] Step 5: Commit
git add go/internal/esign/app/service.go go/internal/httpapi/handlers_esign.go go/internal/httpapi/server.go
git commit -m "feat(esign): GET /api/v1/esign/ca publishes the in-house trust anchor (JSON/PEM/DER)"
Task 4.2: OpenAPI for /esign/ca + regen
Files:
- Modify: api/openapi.yaml
- [ ] Step 1: Add the path (JSON default form only — the pem/der downloads are opened via a plain link, not the typed client):
/api/v1/esign/ca:
get:
operationId: getOrgCA
summary: Get the in-house CA (trust anchor)
description: >-
Returns the organization's in-house signing CA certificate plus its SHA-256
fingerprint and validity, for distribution to trust stores (MDM/GPO). Append
?format=pem or ?format=der for a downloadable certificate. Requires the esign
module and document.read.
tags: [esign]
responses:
'200':
description: The in-house CA public info.
content:
application/json:
schema:
type: object
required: [pem, sha256_fingerprint, subject, not_before, not_after]
properties:
pem: { type: string }
sha256_fingerprint: { type: string }
subject: { type: string }
not_before: { type: string, format: date-time }
not_after: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
-
[ ] Step 2: Regen + verify —
cd web && npm run gen:api && npx tsc --noEmit && npx vite build. -
[ ] Step 3: Commit
git add api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(api): openapi + client for getOrgCA"
Task 4.3: UI — "Verify signatures" action + badges
Files:
- Modify: web/src/api/document-detail.ts (add useVerifyDocumentVersion near the signatures hooks ~299-323)
- Modify: web/src/features/documents/DocumentDetailView.tsx (SignaturesSection ~848)
- Modify: the documents i18n file (grep for where docview.tabs.signatures is defined) — add en+id keys
- [ ] Step 1: API hook — in
document-detail.ts:
// Honest per-signature verification (re-checks the stored PDF).
export interface VerifySignature {
signerName: string
subjectCn: string
integrityOk: boolean
issuerTrusted: boolean
timestampPresent: boolean
timestampTime?: string
timeSource: 'tsa' | 'claimed'
claimedTime?: string
certNotAfter: string
}
export function useVerifyDocumentVersion(docId: string, version: number) {
return useMutation({
mutationFn: async (): Promise<VerifySignature[]> => {
const res = await api.GET('/api/v1/documents/{docID}/versions/{version}/verify', {
params: { path: { docID: docId, version } },
})
return (ok(res).signatures ?? []).map((s) => ({
signerName: s.signer_name,
subjectCn: s.subject_cn,
integrityOk: s.integrity_ok,
issuerTrusted: s.issuer_trusted,
timestampPresent: s.timestamp_present,
timestampTime: s.timestamp_time,
timeSource: s.time_source as 'tsa' | 'claimed',
claimedTime: s.claimed_time,
certNotAfter: s.cert_not_after,
}))
},
})
}
Match the existing import/util style in this file (
api,ok,useMutation/useQueryfrom the same sources the neighboring hooks use). IfuseMutationisn't already imported, add it.
- [ ] Step 2: UI action + badges — in
SignaturesSection(DocumentDetailView.tsx), add a "Verify signatures" button that calls the mutation forcurrentVersionand renders, per returned signature, three CarbonTags: Intact (green/red byintegrityOk), Issuer trusted (green ifissuerTrusted, gray "Untrusted" otherwise), Timestamped (green with the time iftimestampPresent, gray "No timestamp" otherwise). ShowtimeSource("TSA" vs "Claimed") next to the time. Use the existing panel layout + flash/spacing conventions already inSignaturesSection.
Concrete render block (adapt class names to the file's conventions):
const verify = useVerifyDocumentVersion(docId, currentVersion)
// ...
<Button size="sm" kind="ghost" onClick={() => verify.mutate()} disabled={verify.isPending}>
{t('docview.sig.verify')}
</Button>
{verify.data && verify.data.length === 0 && <p>{t('docview.sig.verify_none')}</p>}
{verify.data?.map((v, i) => (
<div key={i} className="sig-verify-row">
<span>{v.signerName || v.subjectCn}</span>
<Tag type={v.integrityOk ? 'green' : 'red'}>{v.integrityOk ? t('docview.sig.intact') : t('docview.sig.tampered')}</Tag>
<Tag type={v.issuerTrusted ? 'green' : 'gray'}>{v.issuerTrusted ? t('docview.sig.trusted') : t('docview.sig.untrusted')}</Tag>
<Tag type={v.timestampPresent ? 'green' : 'gray'}>
{v.timestampPresent ? `${t('docview.sig.timestamped')} · ${new Date(v.timestampTime!).toLocaleString()}` : t('docview.sig.no_timestamp')}
</Tag>
</div>
))}
-
[ ] Step 3: i18n — add to both en and id maps in the documents i18n file:
docview.sig.verify("Verify signatures" / "Verifikasi tanda tangan"),docview.sig.verify_none("No signatures found in this version." / "Tidak ada tanda tangan pada versi ini."),docview.sig.intact("Intact" / "Utuh"),docview.sig.tampered("Tampered" / "Diubah"),docview.sig.trusted("Issuer trusted" / "Penerbit tepercaya"),docview.sig.untrusted("Issuer untrusted" / "Penerbit tidak tepercaya"),docview.sig.timestamped("Timestamped" / "Berstempel waktu"),docview.sig.no_timestamp("No timestamp" / "Tanpa stempel waktu"). -
[ ] Step 4: Verify —
cd web && npx tsc --noEmit && npx vite build. Expected: clean (tsc enforces en/id parity). -
[ ] Step 5: Commit
git add web/src/api/document-detail.ts web/src/features/documents/DocumentDetailView.tsx web/src/features/documents/*i18n*
git commit -m "feat(web): Verify signatures action + honest per-signature badges"
Task 4.4: UI — "Organization CA" admin card
Files:
- Modify: web/src/features/admin/LicensingTab.tsx (add a card below the licensing content)
- Modify: web/src/features/admin/i18n.ts (en+id keys)
- Modify: a web api module for the CA fetch (add a small useOrgCA query — colocate with the admin data or a new web/src/api/esign.ts)
-
[ ] Step 1: API hook — add a
useOrgCAquery callingapi.GET('/api/v1/esign/ca')returning{pem, sha256_fingerprint, subject, not_before, not_after}(map to camelCase). Colocate in the admindata.ts. -
[ ] Step 2: Card — in
LicensingTab, render an "Organization CA" section: subject, validity (not_before–not_after), a copyablesha256_fingerprint(monospace), and two download buttons that open/api/v1/esign/ca?format=pemand?format=derin a new tab (plain anchors — these are authed same-origin GETs, so the session cookie carries). Add one line of guidance from i18n. Follow the existing tab's card/section styling. -
[ ] Step 3: i18n — add en+id keys:
licensing.ca.title("Organization CA" / "CA Organisasi"),licensing.ca.desc("Distribute this certificate to your organization's trust stores (MDM/GPO) so internally-signed documents verify as trusted." / "Distribusikan sertifikat ini ke trust store organisasi (MDM/GPO) agar dokumen bertanda tangan internal terverifikasi tepercaya."),licensing.ca.fingerprint("SHA-256 fingerprint" / "Sidik jari SHA-256"),licensing.ca.download_pem("Download PEM" / "Unduh PEM"),licensing.ca.download_der("Download DER (.cer)" / "Unduh DER (.cer)"),licensing.ca.validity("Valid" / "Berlaku"). -
[ ] Step 4: Verify —
cd web && npx tsc --noEmit && npx vite build. -
[ ] Step 5: Commit
git add web/src/features/admin/LicensingTab.tsx web/src/features/admin/i18n.ts web/src/features/admin/data.ts
git commit -m "feat(web): admin Organization CA card (fingerprint + PEM/DER download)"
Task 4.5: docs/INTERNAL_SIGNING.md
Files:
- Create: docs/INTERNAL_SIGNING.md
-
[ ] Step 1: Write it — cover, in prose: (a) the two tiers — Peruri certified (tersertifikasi, paid, legally strongest) vs Internal (in-house CA, free, uncertified/tidak tersertifikasi under UU ITE 11/2008 & 19/2016, PP 71/2019 — valid but lower evidentiary weight); (b) what B-T adds (a self-hosted RFC3161 timestamp proving signing time;
ESIGN_TSA_URLself-call); (c) distributing the trust anchor — download the CA from Admin → Licensing → Organization CA (PEM/DER), verify the SHA-256 fingerprint out-of-band, push to OS/Adobe trust stores via MDM/GPO so signatures verify as trusted internally; (d) the verify view (per-version "Verify signatures" — integrity / issuer-trusted / timestamp); (e) key-at-rest (BLOB_ENCRYPTION_KEY_FILEencrypts CA + signing keys; unset = plaintext + boot warning); (f) the future roadmap (chain the org CA under a public/PSrE trusted anchor for external validity — recorded in ROADMAP.md). -
[ ] Step 2: Upload the rendered doc (per CLAUDE.md) —
curl -F "file=@docs/INTERNAL_SIGNING.md" https://x056.think.val.id/uploadand give the user the URL. -
[ ] Step 3: Commit
git add docs/INTERNAL_SIGNING.md
git commit -m "docs: internal e-signature tier — TSA, trust anchor, key-at-rest, verify"
Phase 5 — deploy + e2e
Deploy from the repo root and run the spec's Testing checks against the live stack.
Task 5.1: deploy + boot assertions
- [ ] Step 1: Deploy — from repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build. - [ ] Step 2: Boot log —
docker logs deploy-obscura-1 2>&1 | grep -iE 'esign|tsa|encrypt|migrat|listening|panic|fatal'. Expect:http listening, no panic; the key-encryption sweep line (encrypted legacy signing keys at rest converted=N) OR (if the demo key was already applied) nothing; no unset-key WARN (the demo hasdeploy/secrets/blob_age.key). - [ ] Step 3: Modules intact — dev-login and assert
/meenabled_modules == [ai,correspondence,esign,semantic,watermarking](retry a few seconds if dev-login races startup).
Task 5.2: TSA responder e2e
- [ ] Step 1: Timestamp query — generate an RFC3161 request and POST it. Using openssl:
echo "obscura tsa smoke $(date -u)" > /tmp/tsa-data.txt
openssl ts -query -data /tmp/tsa-data.txt -sha256 -cert -out /tmp/tsa.tsq
curl -sS -X POST --data-binary @/tmp/tsa.tsq -H 'Content-Type: application/timestamp-query' \
http://localhost:38080/api/v1/tsa -o /tmp/tsa.tsr -D - | head -20
openssl ts -reply -in /tmp/tsa.tsr -text | grep -iE 'status|time stamp|serial'
Expected: HTTP 200, Content-Type: application/timestamp-reply, Status: Granted, a Time stamp: line at ~now. (The exact host port for obscura is 38080 per compose ports: ["38080:8080"].)
- [ ] Step 2: Cleanup —
rm -f /tmp/tsa-data.txt /tmp/tsa.tsq /tmp/tsa.tsr.
Task 5.3: sign → verify (B-T) e2e
- [ ] Step 1: Sign a document version — dev-login for a token+cookie; create a doc; upload a small PDF version; call the sign route (
POST /api/v1/documents/{id}/versions/{v}/sign) as the internal tier. (Reuse the flow from the existing e2e in memory:POST /auth/dev-login→{token}+cookie;POST /documents→{id};POST /documents/{id}/versionsmultipartfile.) - [ ] Step 2: Verify —
GET /api/v1/documents/{id}/versions/{signedVersion}/verify. Expected JSON: one signature withintegrity_ok=true,issuer_trusted=true(chains to the in-house CA),timestamp_present=true,time_source="tsa", atimestamp_timeat ~now. - [ ] Step 3: Tamper check — download the signed PDF, flip one byte, re-upload as a new version, verify →
integrity_ok=false(or empty list if the byte broke the structure). Alternatively verify a known-unsigned version → emptysignatures. - [ ] Step 4: Cleanup — delete the test document (
DELETE /api/v1/documents/{id}→ 204).
Task 5.4: key-at-rest + anchor + no-op checks
- [ ] Step 1: Keys encrypted —
docker exec deploy-postgres-1 sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tA -c "SELECT count(*) FILTER (WHERE key_pem LIKE '"'"'-----BEGIN AGE%'"'"') AS enc, count(*) AS total FROM (SELECT key_pem FROM ca_certs UNION ALL SELECT key_pem FROM signing_certs) k;"'. Expected:enc == total(all encrypted). Signing still worked in 5.3, proving the decrypt path. - [ ] Step 2: Anchor endpoint —
curl -sS -b cookie http://localhost:38080/api/v1/esign/ca | jq '{subject, sha256_fingerprint, not_after}'; download PEM and check the fingerprint matches:curl -sS -b cookie 'http://localhost:38080/api/v1/esign/ca?format=der' -o /tmp/ca.cer && openssl x509 -inform der -in /tmp/ca.cer -noout -fingerprint -sha256→ matches the JSONsha256_fingerprint. Cleanup/tmp/ca.cer. - [ ] Step 3: TSA-unset no-op (optional, non-destructive to demo): confirm from code/logs that with
ESIGN_TSA_URLempty the signer omitsSignData.TSAand verify reportstime_source="claimed"— this is the default when the env is unset; do NOT restart the demo without the TSA URL just to prove it (the compose default sets it). Note this as verified-by-construction if not exercised live. - [ ] Step 4: Final module + demo check — re-assert
/me enabled_modules; confirm the demo UI loads and an existing document's signatures panel still renders.
Task 5.5: final review
- [ ] Step 1: Dispatch a whole-implementation reviewer (adversarial) over the full commit range for this feature — focus: fail-closed paths (TSA-configured-but-down blocks sign; undecryptable key blocks sign; unset key = plaintext + WARN only), the honest verifier never reports trusted for a non-in-house chain, the TSA route can't be abused (rate limit + 10KiB cap + module gate), no provider-path regressions, and the demo/modules intact.
- [ ] Step 2: Address any blocker/high findings; re-verify build/vet/tsc + redeploy; report the commit list and e2e results. Do NOT push unless the user asks.
Self-Review notes (author)
- Spec coverage: TSA (Comp.1) → Tasks 2.1–2.5; anchor publication (Comp.2) → 4.1–4.5; honest verifier (Comp.3) → 3.1–3.5 + UI 4.3; key-at-rest (Comp.4) → 1.1–1.3. Data-flow, error-handling (fail-closed), and Testing section → Phase 5. All covered.
- Type consistency:
Signer.Verify(signed, caCertPEMs)(3.2) matches the adapter rewrite (3.2) andService.Verify(ctx, signed)(3.3) + handler (3.4).domain.SignatureCheckfields (3.1) match the handler DTO (3.4), the OpenAPI schema (3.5), and the TS interface (4.3).Timestamper.Respond(...)(2.3) matches the adapter (2.3 tsa.go) andNewTSAResponder()wiring (2.5).NewStore(d, cipher)(1.2) matches wire.go (1.3).NewPdfSigner(tsaURL)(2.5) matches wire.go (2.5).IssueTSACert(...)port (2.3) matches x509ca.go (2.2). - Known implementer gotchas flagged inline: the
tsa.gostruct-literal placeholder must be cleaned (deleteCertificates/bytes);newKeyCipher→NewKeyCipherexport;Service.Verifygains actxparam;pg_test.gomust be updated forgo vet; confirms.esign/s.dmsfield names +ctxavailability in wire.go by grepping;/tsais intentionally NOT in OpenAPI (machine endpoint, not consumed by the typed client). These are noted at their tasks, not left as silent assumptions. - Ordering: each phase builds/deploys independently; within Phase 1/2 the
NewService/NewPdfSigner/NewStoresignature changes are interdependent, so the plan says to land those signature changes together (do 2.1+2.3+2.5's wiring in one compiling commit if executing Phase 2 in isolation).