Skip to content

Xfeatures Vault Enclave

A secret vault where the server can be fully compromised and still hand the attacker nothing.

CI Cloudflare Workers Rust License

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"]
Loading

Why it exists

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.

What it guarantees

  • 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 WHERE clause. owner is 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 by cargo 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.

Where it sits

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
Loading
  • 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.

Two kinds of data, one boundary

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.

How a secret gets from a password to a terminal

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
Loading

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.

Memory protection

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.

Documentation

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

Quick start

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.

Building it

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 image

Every 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).

Which edge to use

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

What is not built

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 the vault_users row 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; passwd rotates 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.
  • macOS is 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.

Licence

Dual-licensed under MIT or Apache License 2.0, at your option — the convention for Rust crates. Use whichever your project already standardises on.

About

Edge-synced secret vault: Cloudflare D1 holds ciphertext, a Rust daemon decrypts only into page-locked RAM. The edge never sees a password, a key, or a plaintext byte.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages