Zero-knowledge proof

Sepulchre's central claim is zero-knowledge-by-policy: the operator (a human running the console, or anyone who compromises an operator session) can manage credential requests but can never read the credentials a recipient submits. This document explains how that property is enforced, why it is credible, and how anyone can verify it against a live Vault — in under a minute, with no trust in our word required.

The claim, precisely

No operator-scoped identity has any Vault capability that permits reading its tenant's sepulchre/data/<tenant>/inbound/*.

Submitted credentials live only in Vault KV under sepulchre/data/<tenant>/inbound/<intake-id>. The single identity class allowed to read them is a service account authenticating with an AppRole token (see service-account.hcl and issue #8). Everything else — the operator console, the broker acting on an operator's behalf, the Postgres metadata store — is structurally unable to.

The published artifact

The proof is not a paragraph of prose; it is a policy file you can read and diff:

deploy/docker-compose/vault/policies/templates/operator.hcl.tmpl — a template rendered per tenant ({{TENANT}} → the tenant id), written to Vault as operator-<tenant>:

# ── Inbound: operator can see metadata, NEVER the secret data (per tenant) ──
path "sepulchre/data/{{TENANT}}/inbound/*" {
  capabilities = ["deny"]
}
path "sepulchre/metadata/{{TENANT}}/inbound/*" {
  capabilities = ["list", "read"]
}

Two things make this airtight in Vault:

  1. deny is explicit, not merely absent. The operator can list intake metadata (metadata/inbound/*) so the console can show "fulfilled / pending" — but the secret data path carries an explicit deny.
  2. Vault evaluates deny before allow. No amount of policy stacking, token wrapping, or capability union can override an explicit deny. Even if a future policy mistakenly granted read on the same path, the deny still wins.

Because the secret never enters Postgres (metadata only — see the schema's N-007 note) and never enters a log (issue #15), the Vault policy is the only gate that matters, and it is shut.

Verifying it yourself

The published policy is worthless if the running Vault enforces something looser. The tools/verify-policy.sh script closes that gap: it reads the live policy from Vault and proves the operator is actually denied.

# From the repo root, with the dev stack provisioned:
cd deploy/docker-compose
docker compose -f docker-compose.dev.yml up -d --wait vault
bash vault/provision.sh          # writes policies, mounts, AppRole

# The proof:
cd ../..
bash tools/verify-policy.sh

Expected output:

==> [t_default] comparing live 'operator-t_default' policy to the rendered published template
OK: live policy matches the published artifact (tenant t_default)
==> [t_default] asserting an operator-scoped token is DENIED reading inbound data
OK: operator-t_default denied on sepulchre/data/t_default/inbound/* (zero-knowledge holds)
==> zero-knowledge property verified for tenant 't_default'.

The script does two independent checks:

  1. Policy diff. It reads vault policy read operator-<tenant> from the live cluster, normalizes whitespace/comments, and diffs it against the rendered operator.hcl.tmpl. Any drift → exit 1 with a diff.
  2. Behavioral assertion. It mints a real operator-scoped token and attempts vault kv get sepulchre/t_default/inbound/probe. If that read succeeds, zero-knowledge is broken and the script exits non-zero. The guarantee is proven by the read being denied, not by reading the policy text.

Watch it fail

A guard you have only ever seen pass is not a guard you trust. Grant the operator a read capability on the inbound path and the script rejects it:

docker compose -f deploy/docker-compose/docker-compose.dev.yml exec -T \
  -e VAULT_TOKEN=sepulchre-dev-root vault \
  sh -c 'printf "path \"sepulchre/data/t_default/inbound/*\" { capabilities = [\"read\"] }\n" > /tmp/bad.hcl
         vault policy write operator-t_default /tmp/bad.hcl'

bash tools/verify-policy.sh   # → FAIL + diff, exit 1

Restore by re-running bash deploy/docker-compose/vault/provision.sh (re-renders + writes the per-tenant policies).

Enforced continuously in CI

This isn't a one-time demo. The zero-knowledge-proof job in .gitea/workflows/ci.yml runs on every push and pull request: it stands up an ephemeral Vault, provisions it from the published policies, runs verify-policy.sh, and then deliberately tampers the live policy to confirm the guard still bites. A change that loosens the operator policy — anywhere in the repo — fails the build.

Where this sits in the threat model

Adversary Can they read inbound credentials? Why
Operator (legitimate) No deny on sepulchre/data/<tenant>/inbound/* (their tenant only)
Compromised operator session No Same policy; session inherits operator capabilities only
Compromised broker host No The broker reads inbound only as the caller's service token (issue #8); it holds no inbound-read capability of its own
Postgres compromise No Metadata only — no secret values, nothing that decrypts one
Service account (designated) Yes service-account-<tenant> grants read on its own tenant only; every read is audited (F-028)

The strongest form of the claim is BYOK: each tenant's inbound values are envelope-encrypted under a per-tenant Transit key, and revoking that key makes their data cryptographically inaccessible including to the Sepulchre operator (see docs/architecture.md §8). The POC shipped the policy-layer guarantee above; BYOK was built on top of it in #25.

Requirements traceability