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):
- 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. - No header/token leakage. Traefik access logs drop request headers (no
Authorization/Cookie/X-Vault-Token).Referrer-Policy: no-referrerkeeps URL-embedded tokens out of theRefererheader. HSTS is enforced; links are HTTPS-only and short-TTL. - Static gate. A semgrep rule (
tools/semgrep/secret-leak.yml, CI jobsecret-leak-scan) fails the build if any code path pipes a Vault-read value or a request body into a logger. - Runtime backstop. The audit emitter throws (
MetadataLeakError) before any sink sees an event whose metadata carries a forbidden key (password,value,token, …). - 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.
- 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 samesecret-leak-scangate: (a) a taint rule fails the build if a Vault-read value (or anexposeSensitive()result) flows into a model call (ModelClient.complete/.embed,askModel/embedText); (b) a path-scoped rule fails the build if code under anyai/directory calls the Vault read surface (kvRead/kvReadAs/unwrap) orexposeSensitive/sensitiveat 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:
- Policy-layer zero-knowledge: operator
denyon inbound, proven bytools/verify-policy.shand enforced on every push/PR by thezero-knowledge-proofCI job. - Secret-handling hardening: no body/token logging, semgrep gate, audit metadata backstop, and the AI-boundary guard (#66 — the model never sees plaintext, enforced statically).
- Operator auth: single admin, argon2id,
SameSite=Strictsession cookie, 15-minute expiry. - Single-use reveal with interception signal; scoped, single-fulfilment intake tokens.
- Audit trail to Postgres + JSONL file.
Built after the POC (v0.2 / v1.0) — do not assume these in a bare POC deployment:
- BYOK — built (#25), partial. Each tenant's inbound values are envelope-encrypted under a
per-tenant Vault Transit key before the KV write; the reader decrypts as the caller (the broker
holds no decrypt capability).
byok:revoke <tenant>deletes the key and the tenant's data goes cryptographically dark — including to the operator. Seal stanzas for cloud KMS / HSM roots of trust are ready to apply but not yet boot-tested against a live cloud KMS. - Tamper-evident + immutable audit — built (#24). Each audit event embeds a SHA-256
hash chained to the previous event (
prev_hash) and an HMAC-SHA256signatureunder a per-tenant key derived from a broker-held master key (never in the DB).pnpm --filter @sepulchre/broker audit:verifywalks the chain and fails closed on any post-hoc edit (hash mismatch), deletion/insert/reorder (broken linkage), or forgery (bad signature). The same signed event is shipped to a WORM object-store archive (S3-API + Object Lock, COMPLIANCE mode — the version cannot be deleted/overwritten, even by root, until retention expires) and to a SIEM webhook, so every off-box copy is independently verifiable and un-erasable. - Vault HA — built (#32): 3-node Raft cluster with Transit auto-unseal. Multi-region DR and
Postgres streaming replicas — designed (#39,
ha-dr-plan.md), not built. - OIDC SSO for operators — built (#26), opt-in, with a read-only auditor role; MFA is enforced
by the identity provider, not by Sepulchre. Multi-tenancy hardening — built (#44/#49/#50):
row-level security and per-tenant Vault policies, proven by the
tenant-isolationCI job. Client-side encryption in the intake portal — built (#27), optional.
See docs/architecture.md §7–§12 for the full target-state design and
docs/runbook.md for operational response procedures.