# Svault 0.5.0 — Independent Security Review (GLM-5-1)

**Project**: Svault (nim444/Svault)
**Version reviewed**: 0.5.0 (from Cargo.toml)
**Review date**: 2026-05-29
**Reviewed by**: GLM-5-1 (z-ai)
**Review location**: Cloned locally at `/Users/nk/Projects/nk-os/repos/svault`

---

## Executive Summary

**Overall risk rating for corporate use**: **Moderate — conditionally acceptable for non-production workloads on Unix with the daemon; unsuitable for high-security or Windows environments in its current form.**

Svault 0.5.0 demonstrates thoughtful security engineering — AES-256-GCM with Argon2id, ZeroizeOnDrop keys, HMAC-signed metadata, and a policy engine with tiered access and rate limiting. The new Unix daemon (0.5.0) is the single biggest improvement: it eliminates the on-disk `.session` passphrase file on Unix, holding only derived keys in memory with real auto-lock. This significantly improves the risk posture for Unix deployments. Remaining concerns include: the passphrase still transits in plaintext over the Unix socket during `Unlock`, the file-session fallback on Windows/non-daemon environments stores passphrases on disk with only mode-0600 protection, no authentication/authorization on the daemon socket, no key-rotation support, missing SAST/SCA hardening in CI, and a dependency chain with two advisories (one unsound). For a corporate security committee, I recommend allowing Svault on Unix developer machines with the daemon enabled, but deferring CI/CD and server use until socket auth and key rotation are addressed.

---

## Scope & Methodology

- **Full source review**: All 17 `.rs` files (~7,370 LOC), including `daemon.rs` (937 lines), `client.rs` (128 lines), `crypto.rs`, `vault.rs`, `policy.rs`, `session.rs`, `recovery.rs`, `portable.rs`, `meta.rs`, `audit.rs`, `config.rs`, `passphrase.rs`, `usage.rs`, `main.rs`, and the TUI modules.
- **Documentation**: `docs/security.md`, `docs/daemon.md`, `docs/architecture.md`, CHANGELOG.md.
- **CI/CD workflows**: All 7 GitHub Actions workflows (release, test, lint, ubuntu, windows, macos, fedora).
- **Tools run**: `cargo test` (74 pass, 0 fail), `cargo clippy` (clean, 0 warnings), `cargo audit` (2 warnings, 0 critical vulnerabilities).
- **Special focus**: The daemon architecture, socket model, auto-lock, key lifecycle, and how the client routing layer changes the attack surface compared to the pre-0.5.0 file-session model.

---

## Security Findings

### Critical / High Severity

| # | Severity | Finding | File(s) | Evidence |
|---|----------|---------|---------|----------|
| H1 | **High** | **Passphrase transmitted in cleartext over Unix socket** | `daemon.rs:133`, `daemon.rs:26-41` | The `Request::Unlock { passphrase }` variant sends the raw passphrase over the Unix socket. Any local process with access to the socket (same UID on default Linux, or any UID on systems without `SO_PEERCRED` enforcement) can intercept this via `socat` or a similar tool. The socket is mode 0600, which limits this to the same user — but that is the *same* user running AI agents. |
| H2 | **High** | **No authentication or authorization on the daemon socket** | `daemon.rs:290-308` | Any process that can connect to `.svault/daemon.sock` can issue `Get`, `Unlock`, `Lock`, `LockAll`, and `Shutdown` commands. There is no peer-credential checking (`SO_PEERCRED` on Linux, `getpeereid` on macOS), no challenge-response, and no token-based auth. On shared servers or CI runners, a co-tenant process under the same UID can read all secrets. |
| H3 | **High** | **File-session fallback stores the raw passphrase on disk** | `session.rs:15-29` | On non-Unix platforms (Windows) or when the daemon is not running, `session::unlock()` writes the *raw passphrase* to `.svault/<vault>/.session` (mode 0600 on Unix, no ACL on Windows). This means the root-of-trust plaintext is at rest on disk for the entire session duration. The daemon eliminates this on Unix, but it remains the *default* on all platforms and the *only* option on Windows. |
| H4 | **High** | **Windows has no file permission enforcement** | `session.rs:31-33`, `audit.rs:90-94`, `usage.rs:184-188` | On `#[cfg(not(unix))]`, files are written with `std::fs::write` which uses the process's default DACL — typically inherited from the parent directory and often permissive in corporate environments. There is no `SetACL` call or Windows ACL management. The `.session` file, `audit.log`, and `usage.log` may be readable by other users. |

### Medium Severity

| # | Severity | Finding | File(s) | Evidence |
|---|----------|---------|---------|----------|
| M1 | **Medium** | **No key rotation / re-salting of existing vault secrets** | `vault.rs:149-156` | `save_secrets` re-uses the *existing salt* from `vault.enc` for every write. Over time, the same salt is used for multiple encryptions of different plaintexts, which slightly weakens GCM's IND-CPA guarantees in a theoretical sense (same key+nonce pair must never repeat, and the nonce is random 96-bit so collision probability is negligible at ~2^-32 after 2^32 writes). A re-key operation does rotate the salt, but this is only triggered explicitly. |
| M2 | **Medium** | **Secret values returned as `String` in `Response::Secret`** | `daemon.rs:57-58` | The daemon returns secret values as `String` in JSON over the socket. These strings are allocated on the heap and not zeroized after being sent. The `reply()` function serializes to JSON and writes, but the `String` in the `Response` enum lingers until the enum is dropped — and `Response` does not implement `Zeroize`. |
| M3 | **Medium** | **No concurrency limit on daemon connections** | `daemon.rs:293-302` | The accept loop spawns one thread per connection with no bound. A malicious or misbehaving client can open many connections (resource exhaustion / DoS). There is no connection timeout, no max-connections limit, and no per-client rate limiting at the socket level. |
| M4 | **Medium** | **Daemon log may contain sensitive error messages** | `daemon.rs:146-148` | When `Vault::open` fails, the full `anyhow` error message (which may include file paths, system errors, or even partial content) is sent back in `Response::Error { message }`. These responses are not logged by the daemon, but the `daemon.log` file captures `stderr` from the `run` process, and `eprintln!` at startup (line 387) writes to it. |
| M5 | **Medium** | **Unsound dependency in tree (transitive)** | `Cargo.lock` via ratatui | `cargo audit` flags `lru 0.12.5` (RUSTSEC-2026-0002, unsound) and `paste 1.0.15` (unmaintained), both pulled in by `ratatui 0.29`. While these are not in the crypto or secret-handling paths, an unsound crate in the same address space could theoretically be exploited for memory corruption. |
| M6 | **Medium** | **Policy file search walks up to filesystem root** | `policy.rs:134-145` | `find_policy_file()` walks from CWD upward to `/`, reading `svault.policy.yaml` from any ancestor. A malicious `svault.policy.yaml` placed in `/` or `/home/` by an attacker with write access to a shared ancestor could inject permissive caller rules. There is no path validation or signing of the policy file. |
| M7 | **Medium** | **No `cargo audit` or `cargo-deny` in CI** | `.github/workflows/` | None of the 7 CI workflows run `cargo audit` or `cargo-deny`. Dependency vulnerabilities (like the `lru` unsoundness) would not be caught automatically. |

### Low / Informational

| # | Severity | Finding | File(s) | Evidence |
|---|----------|---------|---------|----------|
| L1 | **Low** | **Passphrase strength check is advisory only** | `passphrase.rs:3-40`, `main.rs:261-270` | The strength warning is shown but the user can proceed with a weak passphrase. No minimum entropy is enforced. |
| L2 | **Low** | **Recovery code is 160 bits but displayed as hex groups** | `recovery.rs:17`, `recovery.rs:30-39` | The 160-bit recovery code is strong, but the hex-grouped format (10 groups of 4) makes it amenable to partial-note-taking errors. No checksum digit is included. |
| L3 | **Low** | **Burst detection is per-vault, not global** | `policy.rs:301-318` | A caller hitting multiple vaults simultaneously could exceed intended burst limits, since each vault's audit log is counted independently. |
| L4 | **Low** | **`Vault::open` decrypts the entire vault to check the passphrase** | `vault.rs:57-73` | `Vault::open` decrypts `vault.enc` (including all secrets) just to verify the passphrase. An HMAC-based separate verification step (separate from the GCM ciphertext) would avoid decrypting secrets unnecessarily during verification. |
| L5 | **Low** | **TUI stores passphrase in `String` (not zeroizing)** | `tui/mod.rs:281-284`, `tui/mod.rs:1145-1166` | The TUI unlock form holds the passphrase as a plain `String`. When the form is dropped, the String's buffer is deallocated but not zeroized. |
| L6 | **Info** | **No reproducible builds / signing of release binaries** | `.github/workflows/release.yml` | Release binaries are built on GitHub runners and attached to the GitHub Release, but there is no signing, no SBOM, and no checksums published. Users cannot verify binary integrity. |
| L7 | **Info** | **`.svault/` gitignored at repo level, but per-vault `.gitignore` is defense-in-depth** | `.gitignore:8`, `vault.rs:32-36` | Good: both repo-level and per-vault gitignore. But `vault.enc` and `meta.yaml` *are* designed to be committed, which means the encrypted vault data is in the repo. This is by design and safe if the passphrase is strong. |

---

## Positive Security Properties Observed

1. **AES-256-GCM with proper nonce generation**: Random 96-bit nonces via `OsRng`, authenticated encryption with GCM tag. Correct implementation.
2. **Argon2id with strong parameters**: 64 MB memory, 3 iterations, parallelism 1. GPU-resistant. Uses version V0x13 (the latest).
3. **ZeroizeOnDrop on VaultKey and SecretStore**: `src/crypto.rs:18-19`, `src/vault.rs:13-14`. Keys and secrets are zeroized when dropped, including in the daemon's `Held` struct (`daemon.rs:119` uses `Zeroizing<[u8; 32]>`).
4. **HMAC-SHA256 signed metadata**: `meta.yaml` is signed with an HMAC derived from the vault key, and verified with a constant-time comparison before parsing. Tampering is detected.
5. **Constant-time HMAC comparison**: `meta.rs:198-206` implements a proper constant-time equality check.
6. **Unix socket mode 0600**: The daemon explicitly sets `0o600` on the socket after binding (`daemon.rs:380`), and the `doctor` command verifies this.
7. **Auto-lock with two timers**: Idle timeout (15 min default, configurable) and hard-max (8h default). Keys are zeroized on eviction. The `is_expired` logic is factored out and unit-tested.
8. **Policy engine is well-structured**: Tier-based sensitivity, scope matching, rate limiting with burst detection, mandatory reason check with placeholder rejection, caller identity verification, and a fallback mode.
9. **Append-only audit log**: Records every policy decision (allow/deny) with caller, scope, tier, reason, and source surface. Never stores secret values.
10. **Recovery code uses proper key wrapping**: 160-bit random code, Argon2id-derived KEK wraps the vault key in `recovery.enc`. Wrong code → GCM auth failure.
11. **Export bundles use SHA-256 integrity checksum**: `portable.rs:28-38` deterministically hashes the file map.
12. **Comprehensive test coverage**: 74 tests including crypto roundtrips, daemon integration (unlock→get→lock→shutdown), concurrent reads (16 threads × 25), policy evaluation, recovery, export/import, and TUI key dispatch.
13. **Clean clippy**: Zero warnings from `cargo clippy --all-targets --all-features`.
14. **Session lock overwrites with zeros before deletion**: `session.rs:39-44`.

---

## The Unix Daemon (0.5.0+) — Detailed Analysis

### Architecture

The daemon is a single-threaded-accept, multi-threaded-serve process bound to `.svault/daemon.sock`. It holds a `HashMap<String, Held>` of vault names to cached keys (wrapped in `Zeroizing<[u8; 32]>`), protected by an `Arc<Mutex<>>`. On `Unlock`, the passphrase is sent over the socket, Argon2id derivation happens server-side, and the resulting key is cached. On `Get`, the key is cloned under the lock and decryption happens outside it — a deliberate design for concurrent read throughput.

### Security Benefits Over the File Session

| Property | File session (pre-0.5.0) | Daemon (0.5.0) |
|----------|------------------------|----------------|
| Passphrase at rest | Yes — plaintext in `.session` file (0600) | **No** — derived key only in memory |
| Auto-lock | None (manual `svault lock` only) | **Yes** — idle timeout + hard-max with background ticker |
| Key zeroization | On `lock` (file overwrite + delete) | On `lock`, auto-lock eviction, and daemon shutdown |
| Crash recovery | `.session` file may persist | Socket is detected as stale; `doctor --fix` cleans up |
| Concurrent access | Multiple CLI processes read the same `.session` | Multiple connections served from same in-memory key |

The daemon is a significant improvement. The elimination of the on-disk passphrase is the single most important security change in 0.5.0.

### Remaining Risks

1. **No socket authentication (H2)**: The 0600 mode restricts access to the same UID, but any process under that UID can read secrets. In an AI-agent workflow, the agent process itself runs under the same UID. Adding `SO_PEERCRED` (Linux) / `getpeereid` (macOS) checking would restrict the daemon to only accepting connections from the process that started it, or from a configured allow-list of UIDs/PIDs.

2. **Passphrase in transit (H1)**: The `Unlock` request sends the raw passphrase. Even though the socket is 0600, any local process can `connect()` to it and issue an `Unlock` with a guessed passphrase. More importantly, a local adversary with `ptrace` or `AF_UNIX` sniffing could capture the passphrase. A better design would derive the key client-side and send only the derived key (or a session token), never the passphrase.

3. **No encryption on the socket**: All data (including secret values in `Response::Secret`) is sent as plaintext JSON over the Unix socket. While Unix domain sockets are not accessible over the network, local interception is possible.

4. **Auto-lock ticker interval is coarse**: The 10-second ticker (`daemon.rs:278`) means a key could linger up to 10 seconds past its expiry. This is a minor risk but worth documenting.

5. **No graceful shutdown on signal**: The daemon does not install a `SIGTERM` handler. It relies on the `Shutdown` request path. If the process is `kill -9`'d, keys may not be zeroized (though the OS will reclaim the memory pages). The `stop_quiet` function does try `SIGTERM` as a fallback, but the daemon itself does not handle it to zeroize keys.

6. **PID file race on `start`**: Between checking `is_running()` and actually spawning the daemon, another process could start a daemon. The PID file is written after `bind()`, so a race is unlikely in practice, but there is no file locking.

### Permission Model

The daemon creates three files in `.svault/`:
- `daemon.sock` — mode 0600, verified by `doctor`
- `daemon.pid` — mode 0600 (set by `OpenOptions::mode(0o600)`)
- `daemon.log` — created with default permissions (no explicit mode set). **This is a minor gap** — the log file is not explicitly set to 0600, though it only captures stderr/stdout from the daemon process.

### Operational Implications

- The daemon is **opt-in**: without `svault daemon start`, everything falls back to the file session. This is good for backward compatibility but means the improved security is not the default.
- The `doctor` command is well-designed for operational debugging: it checks daemon liveness, socket permissions, PID validity, and offers `--fix` for stale state.
- The TUI integration (`d` to toggle) makes the daemon accessible but not accidentally enabled.

---

## Infrastructure, Server & CI/CD Deployment Considerations

### Current Limitations and Improvements Due to the Daemon

- **On Unix servers with the daemon**: The key-at-rest risk is eliminated. The daemon holds keys in memory with auto-lock. This is the recommended deployment mode.
- **On Unix servers without the daemon**: Falls back to `.session` file. The passphrase is on disk for the entire session. This is inappropriate for shared or CI servers.
- **On Windows**: No daemon support. Only the file session is available, with no file permission enforcement beyond default DACLs. This is the weakest deployment.
- **No headless/non-interactive mode**: `svault unlock` and `svault get` (when not using the daemon) prompt for the passphrase interactively via `dialoguer`. There is no `--passphrase` flag or `SVAULT_PASSPHRASE` env var for non-interactive use (by design — to avoid passphrase exposure in process lists). This limits CI/CD utility unless the daemon is pre-started and vaults pre-unlocked.

### Recommended Safe Usage Patterns on Unix Servers and CI Today

1. **Start the daemon at boot** (e.g., via systemd user service) with restrictive `config.yaml` timeouts (e.g., `idle_timeout_secs: 300`, `max_unlocked_secs: 3600`).
2. **Pre-unlock vaults** with `svault unlock <vault>` (which routes through the daemon) during the CI job setup phase.
3. **Use `svault get`** with explicit `--caller` to leverage the policy engine for agent access.
4. **Run `svault daemon doctor --fix`** at the start of each CI job to clean up stale state from crashed runs.
5. **Lock vaults** at the end of each job: `svault lock --all`.
6. **Set umask 077** before running svault commands as an additional defense-in-depth for any created files.

### Risks on Windows and Non-Unix Environments

- **No daemon**: The only session mechanism stores the passphrase in a file with no ACL enforcement. On a shared Windows CI runner, any user with access to the workspace directory can read `.session`.
- **No file permission hardening**: `audit.log`, `usage.log`, and `.session` are created with default permissions. In corporate Active Directory environments, inherited ACEs may grant broader read access than intended.
- **Recommendation**: Do not use Svault on Windows for secrets that would be impactful if exposed to co-tenants of the same machine. If Windows CI is required, use per-job ephemeral VMs.

### Recommendations for High-Value Secrets

- For production database credentials, cloud IAM keys, and signing keys: treat Svault as a *developer workstation* tool, not a replacement for HashiCorp Vault, AWS Secrets Manager, or similar purpose-built infrastructure.
- Use Svault's policy engine to classify high-value secrets as `tier: high` so agents cannot retrieve them automatically.
- Require human review (manual `svault secret get`) for high-tier secrets.

---

## Recommendations for Corporate Adoption

### Priority 1 (Must-address before corporate CI/CD use)

1. **Add peer-credential authentication to the daemon socket**: Use `SO_PEERCRED` on Linux and `getpeereid` on macOS. Reject connections from UIDs/PIDs other than the daemon owner (or a configured allow-list). This addresses H2.
2. **Derive the vault key client-side before sending to the daemon**: Change the `Unlock` protocol so the client derives the key from the passphrase + salt (read from `vault.enc`), and sends only the derived key to the daemon. This eliminates H1.
3. **Add `cargo audit` / `cargo-deny` to CI**: Catch known-vulnerable dependencies before merge. This addresses M7.
4. **Set explicit file modes/ACLs on Windows**: Use the `windows-acl` crate or `std::os::windows::fs::OpenOptionsExt` to set restrictive ACLs on `.session`, `audit.log`, and `usage.log` on Windows.

### Priority 2 (Should-address before broad deployment)

5. **Implement `Response::Secret` zeroization**: Make the `Response` enum's `Secret { value: String }` field use a zeroizing wrapper, or at minimum, clear the String before the Response is dropped.
6. **Sign the policy file**: Add an HMAC or signature to `svault.policy.yaml` (derived from a known key or the vault key) to prevent injection of permissive rules (M6).
7. **Add a separate passphrase verification step**: Store a verification tag (e.g., HMAC of a known constant) alongside the vault to avoid decrypting all secrets just to check the passphrase (L4).
8. **Zeroize TUI passphrase strings**: Use `Zeroizing<String>` in the TUI unlock form and other passphrase-holding form fields.
9. **Add a connection limit to the daemon**: Cap the number of concurrent connections (e.g., 64) and add a per-connection idle timeout.

### Priority 3 (Nice-to-have for maturity)

10. **Sign and publish release checksums**: Add SHA-256 checksums and ideally GPG signatures to GitHub Releases.
11. **Add `--passphrase-env` or `--passphrase-file` support**: For non-interactive CI, allow reading the passphrase from an environment variable or a file descriptor (e.g., `--passphrase-fd 0`). This is less safe than the daemon but more practical than interactive prompts.
12. **Add key rotation / salt rotation on write**: Generate a fresh salt on each `save_secrets` call, and re-encrypt under the new salt+key.
13. **Add SBOM generation to the release pipeline**: Use `cargo-cyclonedx` or similar.
14. **Make the daemon the default on Unix**: Change the behavior so that `svault unlock` attempts to start the daemon automatically if it's not running, making the in-memory path the default rather than opt-in.

---

## Overall Risk Assessment by Context

| Context | Risk Level | Notes |
|---------|-----------|-------|
| **Personal use (Unix + daemon)** | **Low** | Strong crypto, keys in memory only, auto-lock. Remaining risk: socket auth, passphrase transit. |
| **Small team (Unix + daemon)** | **Low-Medium** | Same as personal; socket auth becomes more important on shared machines. |
| **Corporate dev workstation (Unix + daemon)** | **Medium** | Acceptable for developer secrets. Add socket auth before broad rollout. |
| **Corporate CI/CD with AI agents on Unix** | **Medium-High** | Daemon must be pre-started; no headless unlock. Socket accessible to co-tenant processes. Needs H1/H2 fixes. |
| **High-security production infrastructure** | **High** | Not a replacement for purpose-built secret managers. No HSM support, no audit log centralization, no HA. |
| **Windows (any context)** | **High** | No daemon, passphrase on disk with weak permissions. Not recommended for corporate use without ACL hardening. |
| **Mixed Windows/Unix environments** | **Medium-High** | Inconsistent security posture across platforms. Unix users benefit from daemon; Windows users fall back to file session. |

---

## References

- `src/daemon.rs` — daemon implementation (Unix socket, key store, auto-lock, lifecycle)
- `src/client.rs` — daemon-aware client routing layer
- `src/crypto.rs` — AES-256-GCM, Argon2id, VaultKey with ZeroizeOnDrop
- `src/vault.rs` — vault open/create/rekey, secret store
- `src/policy.rs` — policy engine (tiers, scopes, rate limiting, burst detection)
- `src/session.rs` — file-based session (passphrase on disk)
- `src/recovery.rs` — recovery code generation and key wrapping
- `src/portable.rs` — export/import bundles
- `src/meta.rs` — HMAC-signed metadata
- `src/audit.rs` — append-only audit log
- `src/config.rs` — daemon configuration
- `docs/security.md` — documented security model
- `docs/daemon.md` — daemon documentation
- `.github/workflows/release.yml` — release pipeline
- `.github/workflows/lint.yml` — CI lint checks
- [RUSTSEC-2026-0002 (lru unsoundness)](https://rustsec.org/advisories/RUSTSEC-2026-0002)
- [RUSTSEC-2024-0436 (paste unmaintained)](https://rustsec.org/advisories/RUSTSEC-2024-0436)
