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:
denyis 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 explicitdeny.- Vault evaluates
denybeforeallow. No amount of policy stacking, token wrapping, or capability union can override an explicitdeny. Even if a future policy mistakenly grantedreadon the same path, thedenystill 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:
- Policy diff. It reads
vault policy read operator-<tenant>from the live cluster, normalizes whitespace/comments, and diffs it against the renderedoperator.hcl.tmpl. Any drift → exit 1 with a diff. - 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
- N-011 — a Vault policy file exists showing the operator's explicit
denyonsepulchre/data/<tenant>/inbound/*; it is the published proof. ✅operator.hcl.tmpl(per tenant) - F-025 — the operator session has no Vault policy permitting read on
sepulchre/data/<tenant>/inbound/*, enforced at the policy layer. ✅ verified byverify-policy.sh