Threat model

Sepulchre's central promise is zero-knowledge-by-policy: the operator can manage credential requests and one-time shares but can never read the credentials a recipient submits. This document states the trust boundaries, the adversaries we defend against, and — explicitly — what is in scope for the POC versus deferred to later milestones.

It builds on docs/architecture.md §7 (the zero-knowledge guarantee) and §12 (failure modes), and on docs/zero-knowledge-proof.md (the published operator deny policy and its continuous CI enforcement).

Trust boundaries

  Recipient ─┐                          ┌─ Operator (console, SameSite=Strict session)
  (no acct)  │                          │
             ▼                          ▼
        ┌─────────────────── Edge (Traefik / TLS) ───────────────────┐
        │  strict CSP per surface · HSTS · no body/header logging     │
        └───────────────────────────┬────────────────────────────────┘
                                     ▼
                          ┌────────────────────┐
                          │  Broker (stateless) │  holds NO inbound-read capability
                          └─────────┬───────────┘
              ┌──────────────────────┼─────────────────────────┐
              ▼                      ▼                          ▼
        ┌───────────┐        ┌──────────────┐          ┌────────────────┐
        │   Vault   │        │  PostgreSQL  │          │  Audit sinks    │
        │ KV/Transit│        │ metadata only│          │ PG + JSONL file │
        │ deny gate │        │ (PgBouncer)  │          └────────────────┘
        └───────────┘        └──────────────┘
              ▲
              │  the ONLY identity that can read sepulchre/data/inbound/*:
       Service account (Vault AppRole token) ── designated, every read audited

The boundary that matters most: submitted inbound credentials cross from the recipient into Vault and stop there. They never enter Postgres, never enter a log, and are never returned by any operator-facing endpoint. The single gate guarding them is the Vault policy — and it is shut (deny on sepulchre/data/inbound/*).

Adversary → capability → mitigation

Adversary What they can do What they cannot do Mitigation
Legitimate operator Create/revoke intakes & shares; read all metadata (status, expiry, view counts); change tenant settings Read any inbound submission; read a share value back; forge/alter audit events undetectably Explicit deny on sepulchre/data/inbound/* (Vault evaluates deny before allow); share values are written to Vault and never returned by operator endpoints; every operator action is audited
Compromised operator session Everything a legitimate operator can, for the session's lifetime Read inbound (inherits operator capabilities only — none include inbound read); use the session cross-site SameSite=Strict + HttpOnly session cookie (a cross-site POST can't carry it); secure cookie in production; 15-minute inactivity expiry with server-side revocation; CSP form-action 'self'; all actions land in the audit log for anomaly review
Compromised broker host Issue intake submission tokens; create bogus shares; read Postgres metadata Read inbound credentials Design intent: the broker holds no inbound-read capability of its own. The one inbound-read endpoint reads as the caller's service-account token (kvReadAs), so Vault — not the broker — is the authority; an attacker on the host has no service token
Postgres compromise Read/alter metadata (intakes, shares, sessions, audit mirror); read recipient PII Recover any secret value or anything that decrypts one Postgres is metadata only — no secret bodies, no tokens, no decryption material; volume-level encryption at rest; access via PgBouncer, no interactive human read in production
Intercepted reveal link Attempt to reveal a one-time share before the intended recipient Reveal silently — the theft is detectable Single-use unwrap: once the last view is spent the Vault entry is deleted; a second attempt is denied and emits share.reveal / result: denied / reason: exceeded / possible_interception: true; optional sender-set passphrase (argon2id) as a second factor; short TTL; reveal endpoint rate-limited (10/IP/min)
Intercepted intake link Submit values into an intake the attacker shouldn't fulfil Read anything back; reuse the link The submission token is scoped (HS256 JWT bound to one intake id + jti), single-fulfilment (status flips off pending), short-TTL, and revocable; the link grants write, not read — and even the writer can't read its own submission back
Service-account abuse (stolen AppRole credential) Read inbound submissions it is authorized for Act as an operator; escape Vault policy; read silently The only identity allowed inbound read, by design; every read is audited (intake.read, actor_type: service); AppRole secret_id is short-lived and rotatable; tokens are short-TTL (15m / 30m max). A leaked role credential is contained and visible in the audit trail
Insider at the operator Hardest case — privileged access to infrastructure Read inbound at the policy layer; tamper with audit undetectably (target state) Policy-layer deny holds even for privileged human identities; no human shell access to prod (operational layer, §7); audit log is the witness. Strongest form is BYOK (revoking the tenant's key makes its data cryptographically inaccessible to the operator) — built in #25

Defense-in-depth on the secret-bearing paths

The three secret-bearing routes are POST /api/v1/inbox/:id (submit), POST /api/v1/shares (create), and GET|POST /api/v1/reveal/:token (reveal). They are protected at every layer, with no single point of failure (see traefik/dynamic/security.yml):

  1. No body logging, anywhere. Traefik never logs request/response bodies; the broker's structured request log emits the templated route (/api/v1/reveal/:id) only — never the raw path, which itself carries a capability token — and never the body.
  2. No header/token leakage. Traefik access logs drop request headers (no Authorization / Cookie / X-Vault-Token). Referrer-Policy: no-referrer keeps URL-embedded tokens out of the Referer header. HSTS is enforced; links are HTTPS-only and short-TTL.
  3. Static gate. A semgrep rule (tools/semgrep/secret-leak.yml, CI job secret-leak-scan) fails the build if any code path pipes a Vault-read value or a request body into a logger.
  4. Runtime backstop. The audit emitter throws (MetadataLeakError) before any sink sees an event whose metadata carries a forbidden key (password, value, token, …).
  5. Strict CSP on the credential surfaces. The intake portal and reveal page ship the strictest CSP of any surface — no third-party scripts, no analytics, external stylesheets only (no inline styles), zero/minimal JS. They are built and served separately from the operator console so a console compromise cannot reach the intake/reveal DOM.
  6. The AI never sees plaintext (issue #66). The embedded-AI boundary (@sepulchre/ai) consumes metadata only — audit records, policy documents, counts, identifiers. Submitted credential values and unwrapped share secrets are structurally unreachable from any AI code path, enforced by two semgrep rules in the same secret-leak-scan gate: (a) a taint rule fails the build if a Vault-read value (or an exposeSensitive() result) flows into a model call (ModelClient.complete/.embed, askModel/embedText); (b) a path-scoped rule fails the build if code under any ai/ directory calls the Vault read surface (kvRead/kvReadAs/unwrap) or exposeSensitive/ sensitive at all. Any client-side classification (a later phase) stays in the browser and never reaches a model server. This is the load-bearing invariant of the AI epic (#43) — a build-breaking boundary, not a convention.

In scope for the POC vs. deferred

In scope (POC v0.1) — enforced and verifiable today:

Built after the POC (v0.2 / v1.0) — do not assume these in a bare POC deployment:

See docs/architecture.md §7–§12 for the full target-state design and docs/runbook.md for operational response procedures.