secrets-vault 2.4.1

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation

secrets-vault

Crates.io Docs.rs License: MIT

AES-256-GCM encrypted key-value vault for API keys and tokens, instead of plaintext dotfiles. Ships a secrets CLI and an embeddable Rust library.

QVLT v2 encrypts per entry: reading one secret decrypts exactly one record, never the whole vault, and writes splice in without touching unrelated ciphertext.

Install

cargo install secrets-vault          # CLI
[dependencies]
secrets-vault = { version = "2", default-features = false }   # library only, 6 deps

CLI

secrets set STRIPE_SECRET_KEY        # prompts with hidden input
secrets get STRIPE_SECRET_KEY
set KEY [VALUE]     Store a secret (prompts if no value; pass on stdin to keep it off argv)
gen KEY             Generate a random secret — the value is never printed
get KEY             Retrieve to stdout, no trailing newline
delete KEY          Remove a secret
list                List key names (sorted)
env [--json]        Emit all as `export KEY='VALUE'` (or JSON) for eval
import / export     KEY=VALUE lines in / out
migrate             Upgrade a v1 vault to v2 per-entry encryption (keeps a .v1.bak)
rekey               Re-encrypt everything under a fresh salt (run after revoking access)

eval $(secrets env) puts the entire vault in the environment, where every child process inherits all of it — including npm/pip postinstall scripts and AI agents. On macOS, prefer the scoped commands below.

macOS: biometric vault + scoped injection

secrets unlock [--strict]   Store the master key behind Touch ID (Secure Enclave)
secrets lock                Remove it (also ends any session broker + revokes leases)
secrets session [MIN]       One tap starts a grant-checked, audited key broker
secrets exec P -- CMD       Run CMD with ONLY project P's secrets in its environment
secrets lease create P      One tap → project P's declared keys in a TTL-bound
                            encrypted lease; reads need no tap until expiry
secrets lease get P KEY     Read from the lease (no tap; also transparent in get/exec)
secrets lease status        Live leases (purges expired); lease revoke P destroys now
secrets authorize A P       Grant agent A access to project P (Touch ID)
secrets revoke A P          Revoke it
secrets list-projects       Show projects + agent grants

Declare what a project needs in .secrets.tomlnames only, values stay in the vault (see .secrets.toml.example):

[projects.myapp]
secrets = ["DATABASE_URL", "STRIPE_SECRET_KEY"]
secrets exec myapp -- cargo run
# one Touch ID tap → decrypts only those keys → injects into the child → zeroizes

The master passphrase lives in a data-protection Keychain item in a team-prefixed access group: only this Developer-ID-signed binary can reach it, and only after a tap. --strict stores it with BiometryCurrentSet and a zero-reuse LAContext, so every read re-prompts instead of riding macOS's Touch ID grace window.

A lease trades that tap away knowingly, once, for a bounded window: its key sits in a non-biometric Keychain item in the same entitlement-gated access group — tap-free for this signed binary, unreachable for anything else — and the authoritative expiry lives beside the key (not in the agent-writable filesystem), enforced on every read. Tap-free means tap-free for any same-user caller of the CLI: the lease narrows a compromise to one project's declared keys, until expiry, one audited read at a time.

Agent identity is resolved from process ancestry and is a soft layer (spoofable by a same-user adversary); the hard boundary is the Touch ID tap.

Other backends

A project can pull from Google Secret Manager ([gsm]) or any secret manager with a CLI via a generic [backend] block — reads return on stdout and writes are piped to stdin, so values never land in argv. AWS Secrets Manager, HashiCorp Vault, Doppler and 1Password recipes are in .secrets.toml.example.

Library

use secrets_vault::Vault;

let mut vault = Vault::new();
vault.set("API_KEY", "sk-secret-123");

let encrypted = vault.encrypt("passphrase")?;
let vault = Vault::decrypt(&encrypted, "passphrase")?;
assert_eq!(vault.get("API_KEY"), Some("sk-secret-123"));

new from_map get set delete keys iter len is_empty to_map encrypt decrypt to_shell_exports to_json, plus is_valid_key, parse_env_lines, encrypt_blob/decrypt_blob and the v2 MasterSecret / is_v2 helpers.

match Vault::decrypt(&data, passphrase) {
    Err(VaultError::DecryptionFailed) => eprintln!("wrong passphrase"),
    Err(VaultError::BadMagic)         => eprintln!("not a vault file"),
    Ok(vault)                         => { /**/ }
    Err(e)                            => eprintln!("{e}"),
}

Cryptography

Component Algorithm
Encryption AES-256-GCM (NIST SP 800-38D)
Key derivation PBKDF2-HMAC-SHA256, 600k iterations (RFC 8018)
Per-entry / manifest / registry keys HKDF-SHA256
Manifest integrity HMAC-SHA256, constant-time verify
Salt / nonce 128-bit / 96-bit, random, fresh per save

Authenticated encryption — tampered data is rejected, not decrypted to garbage. Secret values are zeroized on drop. A wrong passphrase fails GCM authentication immediately.

The file format is binary-compatible with the Zig implementation.

Environment

Variable Description
SECRETS_PASSPHRASE Passphrase for non-interactive use. Convenient for CI; it is visible to anything that can read the process environment, so prefer unlock + exec on macOS. Stripped from exec children.
SECRETS_DIR Vault directory (default ~/.config/secrets)
SECRETS_GSM_ACCOUNT Override the active gcloud account for the GSM backend
SECRETS_GSM_IMPERSONATE Service account to impersonate for GSM
SECRETS_APPROVAL_DIR Approval handshake dir (default ~/.secrets/pending_approvals)
SECRETS_APPROVAL_TIMEOUT_SECS Approval wait before failing closed (default 30)

License

MIT