| Property | Implementation |
|---|---|
| Encryption | AES-256-GCM |
| Key derivation | Argon2id (64 MB memory, 3 iterations) — GPU-resistant |
| Unlock (keyslot model) | A random 32-byte data key encrypts each store — every vault and the keyring; it is wrapped under a random master key (MK) in a keyslot (<vault>/keyslot.enc or .svault/keyring.keyslot.enc), and MK is wrapped under your master passphrase (Argon2id) in .svault/master.enc. One passphrase unlocks everything; changing it rewraps only MK, never any ciphertext. MK can also be wrapped under a YubiKey (FIDO2 hmac-secret) in master.yubikey.enc or a 160-bit recovery code — any one slot opens it (an or, not a second factor) |
| Metadata integrity | HMAC-SHA256 — tampering with the public meta.yaml is detected |
| Policy at rest | The full policy surface (classification, caller rules, access, the vault's judge assignment) is AES-256-GCM encrypted inside vault.enc — unreadable at rest, so an agent can't read it to plan a bypass |
| Global config at rest | The judge registry, every judge's API key, their criteria/thresholds, and operational knobs are AES-256-GCM encrypted inside keyring.enc, opened by the master passphrase (data key wrapped in keyring.keyslot.enc) — no plaintext config file, no plaintext key file |
| Memory safety | VaultKey, returned secret values, prompts, and the daemon's reply buffer are Zeroizing/ZeroizeOnDrop — wiped after use. (The transient decrypted secret map built while reading a vault is freed but not individually wiped — best-effort residue, consistent with the cooperative same-UID model.) |
| Session file | Created atomically with mode 0600, never at permissive permissions |
| Vault file | Safe to commit to git — encrypted at rest |
The master passphrase is the root of trust — or a vault's recovery code, which is an equal-strength second keyslot into that vault. A strong master passphrase combined with Argon2id makes brute force impractical on current hardware; the recovery code is 160 bits of randomness. The vault data keys themselves are random and never derived from the passphrase, so the passphrase only ever unwraps the master key.
vault.enc, meta.yaml, recovery.enc, keyslot.enc, master.enc, master.recovery.enc, master.yubikey.enc (+ its non-secret master.yubikey.meta), keyring.enc, and keyring.keyslot.enc are safe to commit to git — they are useless without the master passphrase, the enrolled YubiKey, or a recovery code. (keyslot.enc holds the vault's data key wrapped under the master key; keyring.keyslot.enc holds the keyring's data key wrapped under the master key; master.enc holds the master key wrapped under your passphrase; master.recovery.enc holds the master key wrapped under the master recovery code; recovery.enc holds the vault data key wrapped under that vault's recovery code — see Recovery. keyring.enc is the encrypted global config — judge registry, API keys, knobs.) The .session, the master session .master.session, the keyring's .keyring.session, audit.log, usage.log, the daemon's daemon.sock / daemon.pid / daemon.log, and any local lock state are always gitignored (.svault/ itself is gitignored, and a per-vault .gitignore is written at create time and self-heals to add the log lines on first use) and created with mode 0600 (owner read/write only).
Two ways to stay unlocked, both owner-only:
- File session (default, all platforms) —
svault unlockcaches the vault's derived key (32 bytes, hex) plus an unlock timestamp in.svault/<vault>/.session— never the passphrase. A stolen.sessionopens that one vault, but doesn't reveal the passphrase (which may protect other vaults or services). The file is owner-only (mode0600on Unix; anicaclsowner-only ACL on Windows). It expires 6 hours after unlock — a read past the cap treats the store as locked and clears the file, so the master must be re-entered (the same hard cap the daemon enforces, bounding the window an already-unlocked vault can be read). Onlockit's overwritten with zeros and deleted. The master and keyring sessions carry the same 6-hour cap. - Daemon (Unix, opt-in) — when
svault daemon startis running, the client derives the key locally and hands only the key (never the passphrase) to the daemon, which keeps it in memory only; no.sessionfile is written. Keys are zeroized on lock, on idle (15 min) / hard-max (6 h) auto-lock, onSIGTERM/SIGINT, and on shutdown. The socket is0600(bound under a tight umask so there's no world-readable window) and the daemon refuses any connecting peer whose UID isn't your own (getpeereid). It exposes no write operations — only scoped reads. See Daemon.
.svault/ and each vault directory are created 0700, so other local users can't traverse in. recovery.enc and export bundles are written owner-only too (they wrap a key-equivalent).
The agent path (svault get) is enforced inside the daemon — the component
that holds the key. The daemon evaluates policy, consults the AI judge for
sensitive secrets, writes the audit record (stamped with the connecting process's
peer UID, which — unlike the self-asserted --caller — can't be forged), and
only then returns a value. The CLI runs the identical gate locally when no daemon
is up, so there is no unguarded read path.
The entire policy surface — per-secret classification
(scope/tier/require_reason/description), caller rules, access fallback, and the
per-vault judge assignment (which named judge gates the vault) — is AES-256-GCM
encrypted inside vault.enc, not the plaintext meta.yaml. A same-UID process
can therefore neither read the policy at rest to learn what reason or scope
would pass (no reconnaissance) nor tamper with a tier or caller without the
passphrase. The public meta.yaml keeps only non-sensitive metadata (name,
description, storage, created-at, version, settings).
Denials are opaque to the caller. A denied svault get returns only a generic
message; the detailed reason (judge score and rationale, scope/caller mismatch,
rate limit) is recorded in the audit log for the human, never returned to the
caller — so an agent can't hill-climb a denied request into a passing one.
This raises the bar for cooperative and semi-trusted agents and produces a tamper-resistant audit trail. It is not a sandbox against a hostile same-UID process (see Threat model below).
There is no plaintext config file and no plaintext key file. All global config
lives in a single AES-256-GCM-encrypted keyring at .svault/keyring.enc, opened
by the master passphrase — the keyring has a random data key wrapped under the
master in .svault/keyring.keyslot.enc, exactly like a vault (no separate keyring
passphrase). It holds the judge registry, each judge's API key, each judge's
criteria and thresholds, and the operational knobs (lock timers, daemon
max_connections, backend). Nothing abusable is readable at rest: a same-UID
agent can no longer read thresholds or criteria to tune a passing request, nor lift
the API key from a plaintext file.
Unlock the keyring once per session with svault keyring unlock — or just
svault unlock, which opens it along with your vaults (a 0600 session caches its
data key, exactly like a vault); svault keyring lock clears it and the judge goes
back to off; svault master rekey changes the master that opens it (there is no
keyring rekey). Until the keyring is unlocked the judge is off and the static tier
rules apply. The
daemon reads the operational knobs from the keyring at start (built-in defaults
until unlocked; lock, connection, and backend changes apply at the next daemon
start) and resolves the judge per request, so the judge activates the moment the
keyring unlocks.
Honest boundary: the keyring is exactly as protected as a vault — it closes
the read-at-rest path, but it is not a sandbox against a hostile same-UID
process that reads the unlocked daemon's memory or the 0600 session.
For medium- and high-tier secrets (and any require_reason secret) the daemon
asks an LLM, via your OpenRouter account, whether the stated reason plausibly
justifies the request. The judge is a registry of multiple named judges in the
keyring — each with its own model, base_url, timeout_secs, allow_threshold
(minimum score for medium), high_threshold (minimum score for high), free-text
criteria (added to that judge's prompt), and its own API key. A vault is
assigned a judge by name (stored encrypted in the vault policy); if unassigned,
it uses the keyring's default judge. Manage all of it with
svault judge add|edit|remove|list|set-default|set-key|enable|disable|test on the
unlocked keyring.
Each judge's API key is encrypted in the keyring, never written to a file. An
empty stored key falls back to the opt-in $SVAULT_OPENROUTER_KEY environment
variable — env only, never a key file. On a server, export that variable where the
daemon starts. The judge is off until the keyring is unlocked, the global switch
is on, and the resolved judge has a key, so upgrading never silently calls out.
Verify with svault judge test [--judge <name>]. Failure modes are
tier-dependent: medium fails open (allow, with a judge-unavailable audit
flag); high fails closed (deny).
- Svault protects secrets at rest and gates agent access. It does not defend against a compromised machine that already holds your unlocked session (file or daemon), nor against a hostile same-UID process, which can read the daemon's memory directly. The policy and judge gate is for cooperative and semi-trusted agents, plus audit and anomaly detection — not a same-UID sandbox.
- The judge sends the secret name, scope, tier, caller, reason, and any vault or secret descriptions you set (never the value) to your configured OpenRouter model. Keep descriptions free of sensitive data, and factor that third-party call into your data-handling posture.
- HMAC signing detects tampering with
meta.yaml, but anyone with the passphrase can decrypt the vault — treat the passphrase as the root of trust. - The audit log records policy decisions and reasons; the usage log records actions (by human or agent, and the surface they came through — CLI, TUI, GUI, MCP). Neither ever stores secret values.
See Architecture for how these pieces fit together on disk.