A secret vault where the server can be fully compromised and still hand the attacker nothing.
vauclave is a command-line secret manager. Cloudflare D1 holds ciphertext and
nothing else — no usernames, no password hashes, no plaintext. A small Rust
daemon pulls one account's ciphertext, derives that account's key with
Argon2id, decrypts into page-locked RAM, and serves it over a local socket.
Nothing decrypted ever touches a disk.
Recommended: use the already-running hosted edge at
https://enclave.xfeatures.net — see Which edge to use.
Self-hosting your own Worker + D1 is fully supported and documented, but
optional.
flowchart TB
subgraph derive["On the host — before anything reaches Cloudflare"]
direction TB
user["username"] --> handle["handle<br/>BLAKE3-keyed(site_key, 'xfve/handle/v1' ‖ user)<br/><i>what the edge sees</i>"]
user --> salt["user_salt<br/>BLAKE3-keyed(site_key, 'xfve/salt/v1' ‖ user)<br/><i>never leaves the host</i>"]
pass["password"] --> kek["KEK<br/>Argon2id(pw, user_salt, secret = pepper)<br/>128 MiB · t=15 · p=4"]
salt --> kek
end
kek --> aead["XChaCha20-Poly1305<br/>AAD = domain ‖ version ‖ owner ‖ id"]
subgraph cf["Cloudflare"]
direction TB
worker["Worker (itty-router)<br/>GET/POST /api/vault/sync · /users · /audit"]
d1[("D1<br/>vault_items · vault_users · audit_logs")]
worker --> d1
end
handle -.->|"addresses this account's row"| worker
aead <-->|"ciphertext + blinded handles only"| worker
aead --> store["SecretStore<br/>mlock'ed / VirtualLock'ed, guard-paged"]
store --> cli["vauclave CLI<br/>UDS + SO_PEERCRED (Unix) / loopback (Windows)<br/>+ constant-time bearer token"]
Most "encrypted at rest" secret stores put the plaintext in the operator's hands the moment a request is authenticated: the database sees the password, or the server holds a key that decrypts everyone's secrets. That means one compromised process — a bad deploy, a stolen API token, a subpoena served on the wrong company — reaches every account's plaintext at once.
This system is built so that the edge (Cloudflare) is not that process. It never receives a password, never derives a key, and never sees a byte of plaintext. It cannot be tricked, subpoenaed, or hacked into revealing what it was never given.
- No cryptographic parameter ever comes from the edge. The site key and
the Argon2 cost parameters live only in the daemon's local configuration. A
fully compromised Worker cannot serve a chosen salt (precomputation against
a target's password) or weakened KDF parameters (
m=8, t=1) — the only bytes the daemon accepts back are a nonce and a ciphertext, and one of those is useless alone while the other is authenticated by Poly1305. - There is no password hash anywhere. A login succeeds exactly when the
derived key opens the account's reserved
__auth__item and the plaintext equals a fixed constant, compared in constant time. Nothing in daemon memory or in D1 can be stolen and cracked offline except the ciphertext itself, at one 128 MiB Argon2id derivation per guess. - The account boundary is cryptographic, not a
WHEREclause.owneris bound into the AEAD associated data, so a Worker that deliberately serves Alice's row to Bob's session produces a ciphertext that fails authentication under both keys. Covered bycargo test(ciphertext_cannot_be_relocated_to_another_account). - Nothing decrypted reaches disk. No cache file, no swap file, no crash dump. See Memory protection.
- A rejected request is never attributed to a session. Five requests carrying any wrong token, or none, used to wipe every logged-in account on the host — see Fixed after audit. That entire class of bug is now structurally impossible: rejection and session state are handled by code that cannot see each other.
The cost: lose the site key and every account becomes unrecoverable, even with the correct password. It is not a secret in the cryptographic sense — losing it to an attacker reveals nothing by itself — but back it up with the same care as the passwords it blinds.
flowchart LR
cli["vauclave CLI"] -->|"local socket<br/>(UDS / loopback)"| daemon
daemon["vauclave daemon<br/>(page-locked RAM)"] -->|"HTTPS, ciphertext only<br/>bearer token"| worker
worker["Cloudflare Worker<br/>(itty-router)"] --> d1[("D1<br/>vault_users · vault_items · audit_logs")]
daemon -. "async, best-effort" .-> worker
- The CLI never sees a key. It talks to the daemon over a local socket with a bearer token and gets back JSON or raw payload bytes.
- The daemon is the only place a password or a decrypted secret ever exists. It holds them in guard-paged, locked memory and nowhere else.
- The Worker is a ciphertext relay with version-guarded writes, a byte and item quota per account, and a closed-vocabulary audit log. It cannot decrypt anything it stores.
| What Cloudflare sees | What it means | |
|---|---|---|
vault_users.handle |
64 hex characters | A BLAKE3-keyed hash of the username. Cloudflare can count accounts, not name them. |
vault_items.id |
e.g. database/prod-postgres |
The category/slug, in plaintext — operational metadata, not a secret. |
vault_items.encrypted_payload |
base64 ciphertext | The item's kind (binary/text/JSON/PEM), its pretty label and its bytes, all inside the AEAD. Cloudflare cannot tell a password from a certificate. |
vault_items.version |
an integer | Enforced monotonic by both the Worker and the AEAD associated data — see Rollback resistance. |
sequenceDiagram
participant U as vauclave login
participant D as daemon (page-locked)
participant W as Worker
participant DB as D1
U->>D: username, password (raw bytes, one local request)
D->>W: GET /api/vault/sync?owner=handle
W->>DB: SELECT ciphertext for that handle
DB-->>W: rows (or none)
W-->>D: ciphertext + nonces
D->>D: Argon2id(password, user_salt, pepper) → KEK<br/>~1s, 128 MiB, on a dedicated thread
D->>D: open __auth__ with KEK, must equal AUTH_MAGIC
D-->>U: session token (login succeeded ⇔ decryption succeeded)
Note over D: KEK now lives only in locked RAM,<br/>for the life of the session
U->>D: get "Prod Postgres" (bearer token)
D-->>U: plaintext, ~12ms — no re-derivation
login is the expensive step by design — one Argon2id derivation, ~1 second,
128 MiB. Every command after it is a local socket round trip against the
already-derived key: measured 12 ms for status against ~1 s if each command
re-derived the key from scratch.
| Guarantee | Linux | Windows |
|---|---|---|
| Never swapped | mlock per allocation + mlockall(MCL_CURRENT|MCL_FUTURE) |
VirtualLock + raised working-set minimum |
| Guard pages | PROT_NONE on both sides |
PAGE_NOACCESS on both sides |
| Excluded from dumps | madvise(MADV_DONTDUMP) + RLIMIT_CORE=0 + PR_SET_DUMPABLE=0 |
SetErrorMode(SEM_NOGPFAULTERRORBOX) + panic hook wipes first |
| Not inherited by fork | madvise(MADV_WIPEONFORK) |
n/a |
| Debugger attach blocked | PR_SET_DUMPABLE=0, Yama ptrace_scope |
not blocked |
| Wiped on drop | volatile write + fence (zeroize) |
same |
mlockall(MCL_FUTURE) is the control that actually matters on Linux:
per-page mlock cannot cover Argon2's 128 MiB matrix, hyper's write buffers,
or thread stacks. It is applied before any thread is spawned, because
MCL_FUTURE only binds threads created afterwards.
The Windows build is weaker — no MADV_DONTDUMP equivalent, no
ptrace_scope, and SetProcessWorkingSetSizeEx is missing from
windows-sys 0.59 so it is declared by hand. Where the debugger-attach
barrier matters, run the Linux/musl build. Full detail, including exactly
which attacks each control stops, is in
docs/ARCHITECTURE.md.
| Document | What it covers |
|---|---|
| docs/ARCHITECTURE.md | Components, the envelope format, rollback resistance, memory layout, D1 schema, in depth |
| docs/THREAT-MODEL.md | What's blocked and how, the full audit history (XVE-01…09), what the edge learns if fully compromised |
| docs/SECURITY-ASSUMPTIONS.md | Known limitations and what the guarantees depend on — stated plainly, not implied away |
| docs/CLI-REFERENCE.md | Every command, categories, password policy, quotas, output conventions |
| docs/API.md | The daemon's local HTTP API and the Worker's edge API |
| docs/BUILDING.md | Verified build instructions for every target: Windows, Linux/musl, Docker, the Worker |
| PROMPTS/ | Copy-paste prompts for setting this up on a fresh machine with an AI coding agent |
vauclave register # prompts for a name, then a password twice
vauclave login # ~1s Argon2id, then the daemon holds the key
vauclave put secret "Prod Postgres" --category database
vauclave ls
vauclave get "Prod Postgres"No environment variables, no flags, nothing to configure — the defaults find
~/.xfeatures/{site.key,edge.token} and start the daemon on first login.
Full command reference: docs/CLI-REFERENCE.md.
cargo build --release --target x86_64-pc-windows-msvc # static CRT, /GUARD:CF — verified
cargo build --release --target x86_64-unknown-linux-musl # static musl, FROM scratch — verified
docker build --target check . # fmt + clippy -D warnings + tests
docker build -t vauclave:latest . # the shipping imageEvery one of those has actually been run against this tree, not just
described — see docs/BUILDING.md for exact commands,
output, and what each platform's build catches that the others cannot (the
Linux build alone compiles the mlockall/PR_SET_DUMPABLE/UDS code path, and
finding out it doesn't compile is not a thing you want to learn in
production).
Two options, and they are not equally recommended:
- Use the hosted deployment at
https://enclave.xfeatures.net(the CLI's default--edge-url— no configuration needed). This is a running Worker + D1 instance, already deployed, already covered by the hardening in docs/THREAT-MODEL.md. All you need is a bearer token from whoever operates it — ask them, the same way you'd ask for access to any shared service. This is the recommended path: fewer moving parts, nothing to keep patched or monitor, and the deployment has already been through the audit history documented in this repository. - Deploy your own Worker + D1, if you specifically need infrastructure you control end to end — a different Cloudflare account for compliance reasons, a policy against depending on someone else's edge, or simply not wanting to share a deployment with anyone else's accounts. This is more to operate (your own D1 backups, your own secret rotation, your own monitoring) for the same cryptographic guarantees, since the edge never sees plaintext either way. See PROMPTS/deploy-worker.md or docs/BUILDING.md § Worker.
One clarification either way: a Worker's bearer token
(VAULT_API_TOKEN) is one secret per deployment, shared by every daemon
that talks to it — it is not an individual credential issued per user. Account
isolation happens one layer down, at the per-account key derivation; the
token only says "this daemon may talk to this edge at all." Using the hosted
deployment means being handed that one token by its operator, the same as
joining any shared backend.
The reference hosted instance:
| Worker | xfeatures-vault-enclave-edge |
| D1 database | xfeatures-vault (b8f81ad1-dc2d-4249-b93f-a9e6baf3a7e5) |
| Routing | Custom domain only — no *.workers.dev fallback, so there is exactly one hostname to put behind Cloudflare Access or mTLS |
Being direct about the edges rather than implying more than is there:
- No account deletion endpoint. An account can be locked out
(
disabled = 1) and every item removed, but thevault_usersrow itself is not deletable through the API. Removing one is an operator-run SQL statement against D1. - No key rotation for the site key itself. Rotating it would re-blind
every username and break every existing handle;
passwdrotates the per-account password and re-encrypts, but the site key is meant to be set once and backed up, not rotated. - No mobile or browser client. The CLI and the local daemon API are the only interfaces. A browser reaching the daemon's loopback port is refused outright — see docs/THREAT-MODEL.md.
- No hardware-backed key storage (TPM, Secure Enclave, YubiKey). The Argon2id-derived key lives in locked RAM and nowhere more exotic.
macOSis refused at compile time. No verified page-locking path exists for it yet, and shipping an enclave that silently lets secrets reach swap is worse than refusing to build. Docker (Linux inside) is the way to run this on a Mac today.
Dual-licensed under MIT or Apache License 2.0, at your option — the convention for Rust crates. Use whichever your project already standardises on.