Security
This page describes what Vaulted protects, how, and where the protection ends. Every claim maps to implemented, tested behavior in the repository. The boundaries at the end are as much a part of the model as the guarantees.
Key hierarchy
Master password│ Argon2id (hash-wasm), 64 MiB memory, 3 iterations, 4 lanes, 32-byte output▼AES-256-GCM master key never stored, derived on demand, non-extractable│ encrypts (AES-256-GCM, 12-byte IV prepended, base64 frame)▼User RSA-2048 private key PKCS8, stored encrypted, public key stored plaintext│ unwraps (RSA-OAEP, SHA-256, exponent 65537)▼Project AES-256-GCM key random per project, stored only RSA-wrapped│ encrypts (AES-256-GCM)▼Secret values only ciphertext ever touches disk
All symmetric encryption is AES-256-GCM with a random 12 byte IV prepended to the ciphertext and the whole frame base64 encoded. Tampering with any byte of a frame, IV, body, or tag, fails authentication and surfaces as a typed ERR_DECRYPT_AUTH error. A wrong master password fails the same way at the private key stage, before any RSA operation.
The RSA layer looks redundant for a single user local tool, and today it is. It is kept because it makes future vault sharing and machine identities possible without re-encrypting the vault. Service accounts, schema present in v0.3, use their own keypair and an HKDF-SHA256 derived key rather than Argon2id, because a 256 bit random token gains nothing from a memory-hard KDF. The token itself is stored only as a SHA-256 hash plus a 12 character display prefix.
What is on disk
Everything lives under VAULTED_CONFIG_DIR, default ~/.config/vaulted, plus one vaulted.toml per project directory.
| File | Contents | Mode |
|---|---|---|
vaulted.db | The SQLite vault in WAL mode, contents broken down below | 0600, WAL companions inherit it |
password | The master password, verbatim, fallback only | 0600 |
vaulted.toml | Project id and default environment name, no secret material, one per project directory | default |
Inside the database.
- Encrypted. Secret values, the user's RSA private key, service account private keys.
- Wrapped. Project AES keys, RSA-OAEP under the user's public key.
- Plaintext by design. Secret key names like
STRIPE_KEY, needed for list and search, project, environment, and folder names, scopes, value types, descriptions, tags, the user's public key, the Argon2id salt, token hashes and prefixes, and the audit log. This is the same tradeoff every zero-knowledge product makes, stated openly. If key names themselves are sensitive, Vaulted does not hide them.
The store package never sees plaintext values or key material. Values arrive already encrypted as a branded EncryptedString type, and the type system plus tests keep decryption out of that package entirely.
Master password lifecycle
Resolution order at runtime.
VAULTED_PASSWORDenvironment variable, for CI and automation, empty means unset.- OS keychain via
Bun.secrets, serviceso.vaulted.cli, entrymaster-password. Skipped entirely whenVAULTED_NO_KEYCHAIN=1or the API is unavailable on the platform. - The
~/.config/vaulted/passwordfile,0600. - Interactive hidden prompt, TTY only. The MCP server never reaches this step, it stops after the file, because stdio is the protocol channel.
On save, the keychain is tried first and the file fallback is deleted on success. vaulted lock clears both the keychain entry and the file.
There is no password recovery of any kind. No reset flow, no escrow, no security questions. A lost master password means an unreadable vault, by design. Export what you need while you can still decrypt.
The Rust runner
Every execution path that touches a decrypted secret goes through one Rust cdylib, packages/runner, loaded via Bun.dlopen. Both vaulted run and the MCP run-with-secrets tool call the same library through the same FFI bridge. A source scan test asserts that no JS level process API, Bun.spawn or child_process, appears anywhere in the CLI sources, so the hardening below cannot be bypassed by accident.
What the runner does, in order.
- Sets
RLIMIT_CORE = 0, soft and hard, before anything else, so no secret laden core dump can ever be written. - Clears the child environment completely, then re-inherits exactly ten variables from the parent,
PATH,HOME,USER,SHELL,TERM,LANG,LC_ALL,LC_CTYPE,TMPDIR, andTZ, then applies the secrets on top. Parent environment leakage, includingVAULTED_PASSWORD, is covered by tests that spawn/usr/bin/envand compare. - Holds the raw secrets input in
Zeroizingbuffers, moves every value into aZeroizingstring, and recursively zeroizes the transient JSON parse buffers before drop. The TypeScript caller zeroes its own serialized buffers in afinallyblock. - Forwards SIGTERM and SIGINT to the child. The wait sequence uses
waitid(WNOWAIT)and clears the PID slot before reaping, so a forwarded signal can never hit a recycled PID belonging to an unrelated process. - Validates before spawn and rejects an empty command array, an empty key, a key containing
=or NUL, and a value containing NUL. - Writes nothing to disk anywhere in the injection path.
- Catches panics at every FFI boundary. Null pointers, invalid UTF-8, and invalid JSON return
-1, never a panic across the boundary.
Captured mode exists for the MCP path. Instead of inheriting stdio, the runner pipes stdout and stderr and returns them as zeroize on free buffers, capped at 10 MiB per stream, with the child's stdin attached to /dev/null so an agent chosen command can never read from or block on the MCP protocol channel.
One known limitation is inherent to Rust's std::process. The standard library keeps internal copies of env values between env() and spawn(), and those copies are freed but not zeroized. The authoritative copies Vaulted owns are all Zeroizing. Windows is out of scope for v0.3, the crate compiles stubs that return -1 cleanly with no side effects.
Audit log
The vault database carries an append-only action_log with a closed, typed set of actions. Every mutating store method writes its audit entry inside the same transaction as the mutation, so a committed change is always a recorded change. Covered actions include vault create and lock state, project, environment, and folder lifecycle, secret create, update, delete, reveal, and export, dotenv import and export, run.executed, mcp.list_secrets, mcp.run_with_secrets, key rotation, and service account lifecycle.
Two ordering details matter. run.executed and mcp.run_with_secrets entries are written before the child spawns, so interrupted or long-running commands still leave a record. export.dotenv is written before any plaintext is emitted, so exported plaintext can never exist unrecorded. Audit metadata carries argv, key names, environments, and counts, never secret values.
Threat model boundaries
Vaulted defends against
- Reading the database file, its backups, or its WAL companions. Values are ciphertext under the hierarchy above.
- Secret laden core dumps from the injection path,
RLIMIT_CORE = 0. - Parent environment leakage into launched processes, an allowlist of ten variables.
- Secrets lingering in the runner's memory after use, zeroization.
- Accidental echo of secret values into an agent's context window, redaction on the only MCP execution path.
- Unrecorded access. Reveals, exports, runs, and MCP calls are audited locally.
Vaulted does not defend against
- A compromised local account. Anyone who can run code as your user can read the keychain entry or the password file, call the same APIs Vaulted calls, and decrypt the vault. Vaulted is a local-first tool, the OS account is the trust boundary.
- A malicious command you run.
vaulted run -- evil.shand an agent chosenrun-with-secretscommand both receive the plaintext secrets, legitimately, because delivering them is the point. A program that uses the secret and then exfiltrates it through its own network access is outside Vaulted's control. Vaulted hardens delivery into the child, it does not sandbox the child. - Root, kernel level attackers, or memory inspection of a live process. Plaintext exists in the child's environment for as long as the child runs.
- A hostile agent with auto-approved tool calls. Redaction narrows the accidental channel, it is not containment. Keep a human in the tool approval loop until network layer containment ships. The full agent surface is documented on the MCP page.