think
16px
820px

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 via cd go && go build ./... && go vet ./... (note: go vet compiles _test.go files, so test files must stay compilable). Run gofmt -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 any api/openapi.yaml change: 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 obscura or ... obscura web).
  • After every deploy: assert /me enabled_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). The Signer.Sign change is additive (TSA URL); Signer.Verify is a signature change consumed only by internal code + one test.
  • These are a plan, not TDD-with-go test. Each task's "verify" step is go 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.go files that would require go test to 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.goStore 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.goTimestamper impl over digitorus/timestamp.
- Modify go/internal/httpapi/handlers_esign.goTimestampRFC3161 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.goNewPdfSigner(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.ymlESIGN_TSA_URL.

Phase 3 — honest verifier
- Create go/internal/esign/domain/verify.goVerifyResult + 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.goSigner.Verify port signature; Service.Verify loads the CA pool; add Repository.ListCACertPEMs.
- Modify go/internal/esign/adapters/pg.goListCACertPEMs.
- Modify go/internal/esign/adapters/pg_test.go — update the svc.Verify call (keep go vet green).
- Modify go/internal/httpapi/handlers_esign.goVerifyDocumentVersion 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.goCAPublicInfo.
- Modify go/internal/httpapi/handlers_esign.goGetOrgCA handler; Modify server.go — route.
- Modify api/openapi.yaml/esign/ca; regen.
- Modify web/src/api/document-detail.tsuseVerifyDocumentVersion.
- 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 from newKeyCipher to NewKeyCipher (exported) since wire.go (a different package) calls it. Update keycipher.go accordingly. NewPdfSigner(cfg.ESignTSAURL) depends on Task 2.4 (the signature change) and cfg.ESignTSAURL on Task 2.1 — if doing Phase 1 alone, temporarily call NewPdfSigner("") and NewStore(esignStore...); the Phase 2 tasks finalize it. Prefer: do Task 2.1 + 2.4's NewPdfSigner signature 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 ctx is in scope at this point in wire.go (the composition root threads one through). If the local is named differently (e.g. bootCtx), use that. If none exists, use context.Background().

  • [ ] Step 3: Verifycd go && go build ./... && go vet ./... && gofmt -l cmd/obscura-server/wire.go. Expected: clean (assuming NewPdfSigner("") placeholder or Task 2.1/2.4 done).

  • [ ] Step 4: Rename check — ensure keycipher.go exports NewKeyCipher and internal helpers (encrypt/decrypt/isLegacyPlaintext) stay lowercase (same package as pg.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 ESignProvider field (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 depcd go && go get github.com/digitorus/timestamp@v0.0.0-20231217203849-220c5c2851b7 && go mod tidy. This moves digitorus/timestamp from // indirect to a direct require. Verify it's still the same pinned version.

  • [ ] Step 3: Verifycd 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. Below NewX509CA add:
// 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: Verifycd 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 CertAuthority port — add to the interface (after IssueSigningCert):
    IssueTSACert(caCertPEM, caKeyPEM []byte, commonName string) (certPEM, keyPEM []byte, serial string, notAfter time.Time, err error)
  • [ ] Step 2: Add the Timestamper port — after the Signer interface 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 field timestamper Timestamper to the Service struct, a constructor param, and store it. Change NewService:
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 — after signAttestation (~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 adaptergo/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: x509Cert above is a placeholder to avoid importing crypto/x509 twice — DELETE the Certificates line entirely (the field defaults to nil and AddTSACertificate: true is what includes the cert). Final struct literal should omit Certificates. Also delete the bytes import + the bytes.MinRead guard line if unused. Keep only imports actually referenced (crypto, crypto/rand, fmt, math/big, time, digitorus/timestamp, the app package). Run gofmt + go vet to confirm imports are clean.

  • [ ] Step 6: Verifycd go && go build ./... && go vet ./... && gofmt -l internal/esign/adapters/tsa.go internal/esign/adapters/x509ca.go internal/esign/app/service.go. Fix the NewService call site in wire.go to 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 const apiRateTSAMax = 600 // RFC3161 timestamp requests per IP per window; loopback (our own signer) dominates next to the other maxes, and a limiter tsaLimiter = newRateLimiter(apiRateWindow) next to authLimiter.

  • [ ] 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 io and net/http are imported in handlers_esign.go (they are, given existing handlers). s.esign is the esign Service field on Server — 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: Verifycd 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 the sd := sign.SignData{...} literal (right before the if visible { block), add:
        if p.tsaURL != "" {
            sd.TSA = sign.TSA{URL: p.tsaURL}
        }

buildData is a closure inside Sign, which has receiver p *PdfSigner, so p.tsaURL is in scope. A TSA fetch failure makes sign.SignFile return an error → Sign returns 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 near ESIGN_PROVIDER (~126), add:
      ESIGN_TSA_URL: "${ESIGN_TSA_URL:-http://127.0.0.1:8080/api/v1/tsa}"
  • [ ] Step 5: Verifycd 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: Verifycd 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 ListCACertPEMs to the Repository port — add to the interface (near GetCA):
    // 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 Signer port — replace the Verify line in the Signer interface:
    // 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 — replace pdfsign.go's Verify (~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.Signer struct exposes Name, ValidSignature, Certificates []Certificate (each with a Certificate *x509.Certificate), TimeStamp *timestamp.Timestamp, and SignatureTime *time.Time — confirmed in the pinned lib's verify/types.go. The first cert in Signers[i].Certificates is the signer leaf. Update imports: pdfsign.go needs domain (.../internal/esign/domain); crypto/os etc. already present; crypto/x509 already imported. Remove any now-unused imports and gofmt.

  • [ ] Step 5: Verifycd 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.Verify currently has no ctx param. Change its signature to Verify(ctx context.Context, signed []byte) (domain.VerifyResult, error) and use ctx for ListCACertPEMs. Update the doc comment above it. (The only production caller is the new handler in Task 3.4, which has r.Context().)

  • [ ] Step 2: Fix the test callerpg_test.go:~133 currently:
    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 context is imported in pg_test.go (add it if missing). Do NOT run go test; go vet ./... compiles it.

  • [ ] Step 3: Verifycd 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, io imported; s.dms.OpenVersionContent is the same call SignDocumentVersion uses (verified at handlers_esign.go:354).

  • [ ] Step 2: Mount the route — in server.go per-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: Verifycd 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}/signatures path 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 (near SignatureList):
    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 + verifycd 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/sha256 is already used by sha256Hex). Confirm kernel.ErrInternal exists (grep kernel.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: Verifycd 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 + verifycd 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/useQuery from the same sources the neighboring hooks use). If useMutation isn't already imported, add it.

  • [ ] Step 2: UI action + badges — in SignaturesSection (DocumentDetailView.tsx), add a "Verify signatures" button that calls the mutation for currentVersion and renders, per returned signature, three Carbon Tags: Intact (green/red by integrityOk), Issuer trusted (green if issuerTrusted, gray "Untrusted" otherwise), Timestamped (green with the time if timestampPresent, gray "No timestamp" otherwise). Show timeSource ("TSA" vs "Claimed") next to the time. Use the existing panel layout + flash/spacing conventions already in SignaturesSection.

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: Verifycd 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 useOrgCA query calling api.GET('/api/v1/esign/ca') returning {pem, sha256_fingerprint, subject, not_before, not_after} (map to camelCase). Colocate in the admin data.ts.

  • [ ] Step 2: Card — in LicensingTab, render an "Organization CA" section: subject, validity (not_beforenot_after), a copyable sha256_fingerprint (monospace), and two download buttons that open /api/v1/esign/ca?format=pem and ?format=der in 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: Verifycd 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_URL self-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_FILE encrypts 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/upload and 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 logdocker 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 has deploy/secrets/blob_age.key).
  • [ ] Step 3: Modules intact — dev-login and assert /me enabled_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: Cleanuprm -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}/versions multipart file.)
  • [ ] Step 2: VerifyGET /api/v1/documents/{id}/versions/{signedVersion}/verify. Expected JSON: one signature with integrity_ok=true, issuer_trusted=true (chains to the in-house CA), timestamp_present=true, time_source="tsa", a timestamp_time at ~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 → empty signatures.
  • [ ] 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 encrypteddocker 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 endpointcurl -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 JSON sha256_fingerprint. Cleanup /tmp/ca.cer.
  • [ ] Step 3: TSA-unset no-op (optional, non-destructive to demo): confirm from code/logs that with ESIGN_TSA_URL empty the signer omits SignData.TSA and verify reports time_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) and Service.Verify(ctx, signed) (3.3) + handler (3.4). domain.SignatureCheck fields (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) and NewTSAResponder() 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.go struct-literal placeholder must be cleaned (delete Certificates/bytes); newKeyCipherNewKeyCipher export; Service.Verify gains a ctx param; pg_test.go must be updated for go vet; confirm s.esign/s.dms field names + ctx availability in wire.go by grepping; /tsa is 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/NewStore signature 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).