# Independent Security Review — Svault 0.7.0

**Project**: Svault (nim444/Svault)
**Version reviewed**: 0.7.0 (confirmed from `Cargo.toml`)
**Review date**: 2026-05-30T12:00:00Z
**Reviewed by**: z-ai/glm-5.1 (via opencode)
**Review location**: Cloned locally from https://github.com/nim444/Svault

---

## Executive Summary

**Overall risk rating for corporate/medium company use**: **Moderate — with caveats.**

Svault 0.7.0 demonstrates meaningfully stronger security posture than a typical early-stage tool, particularly on Unix with the daemon active. The Unix daemon architecture (in-memory keys, 0600 socket under tight umask, peer-UID bond, auto-lock, graceful shutdown) is well-designed and correctly implemented. The cryptographic choices (AES-256-GCM, Argon2id at 64 MB, HMAC-SHA256 for metadata integrity) are sound and implemented correctly using reputable Rust crates.

However, the **policy engine is purely advisory at the daemon layer** — the `Get` path in `daemon.rs` runs no policy or audit check whatsoever. Caller/scope/reason on `svault get` are self-asserted by the client, and the unsigned policy file is located by an upward directory search. A same-UID process can directly connect to the socket and retrieve any secret from any unlocked vault with a simple JSON line, completely bypassing the policy engine. This is the single most important finding and significantly undercuts the project's headline "AI-aware / gates agent access" claim in its current state. The documentation is honest about the same-UID trust model, but the gap between the marketing language ("enforces structured requests") and the actual enforcement surface is material for a corporate security committee evaluating this tool.

On Unix with the daemon, the at-rest risk is limited to the key-equivalent `.session` file (fallback path). On Windows, there is no daemon — only the file session — and the `icacls` ACL is best-effort. The passphrase entropy floor (~50 bits) is reasonable, and `Zeroizing` wrappers are applied to the right surfaces, though serde/transport copies leave transient plaintext residues that are typical for this class of tool.

---

## Scope & Methodology

**What I did:**

- Cloned the repository and confirmed version 0.7.0 from `Cargo.toml`
- Read every source file in `src/` (18 `.rs` files, ~5,500 lines total), with deep focus on:
  - `daemon.rs` (1,408 lines) — daemon architecture, socket handling, peer-UID check, key lifecycle
  - `client.rs` (138 lines) — client-side key derivation and socket secrecy
  - `policy.rs` (569 lines) — policy engine, enforcement vs. advisory, file loading
  - `crypto.rs` (159 lines) — AES-256-GCM, Argon2id, VaultKey zeroization
  - `session.rs` (126 lines) — file session, key caching, lock zeroization
  - `secfile.rs` (89 lines) — owner-only file/dir creation, Windows ACL
  - `main.rs` (1,412 lines) — CLI routing, policy integration in `cmd_get`
  - `vault.rs` (386 lines) — secret store, zeroization, HMAC metadata
  - `recovery.rs` (181 lines) — recovery code, key wrapping
  - `portable.rs` (314 lines) — export/import bundles
  - `meta.rs` (225 lines) — HMAC-signed metadata
  - `passphrase.rs` (131 lines) — entropy estimation and floor enforcement
  - `tui/mod.rs` — TUI secret reveal, passphrase handling
- Reviewed all CI/CD workflows: `lint.yml`, `test.yml`, `release.yml`
- Reviewed documentation: `docs/security.md`, `docs/daemon.md`, `docs/policy-engine.md`
- Ran `cargo test` — **84 passed, 0 failed, 1 ignored** (stress sim)
- Ran `cargo clippy --all-targets --all-features -- -D warnings` — **clean**
- Ran `cargo audit` — **no vulnerabilities found** (257 crate dependencies scanned)
- Traced the full `svault get` code path from CLI → policy → daemon to verify enforcement
- Traced the peer-UID check on both Linux and macOS code paths
- Verified file permission models across all at-rest sensitive files

---

## Security Findings

### Critical / High Severity

| # | Finding | Severity | Evidence |
|---|---------|----------|----------|
| H-1 | **Policy engine is not enforced at the daemon layer** | **Critical** | `daemon.rs:223-253` — The `Get` handler retrieves any secret from any unlocked vault with no policy check, no audit, and no caller/scope/reason. Any same-UID process can send `{"cmd":"get","vault":"v","secret":"s"}` over the socket and receive the plaintext. The policy check only runs in `main.rs:806-814` (`cmd_get`), which the daemon client never invokes. The daemon has no concept of callers, scopes, or tiers. |
| H-2 | **Policy file is unsigned and located by upward directory traversal** | **High** | `policy.rs:127-145` — `load()` walks `current_dir()` up to the filesystem root looking for `svault.policy.yaml`. A malicious user or CI step can place a permissive policy file in a parent directory and it will be picked up. The file has no signature, HMAC, or checksum — any same-UID process can overwrite it before `svault get` runs. |
| H-3 | **Caller/scope/reason are entirely self-asserted** | **High** | `main.rs:800-803` — The caller defaults to `$SVAULT_CALLER` env var or "default", scope and reason are CLI flags. An AI agent or script can assert any caller identity and any scope. The daemon has no mechanism to verify or constrain who is asking. |

### Medium Severity

| # | Finding | Severity | Evidence |
|---|---------|----------|----------|
| M-1 | **TUI export writes bundle with default (world-readable) permissions** | **Medium** | `tui/mod.rs:813` — `std::fs::write(&out, json)` uses default umask. The CLI path at `main.rs:1134` correctly uses `secfile::write_owner_only`. Export bundles contain the wrapped vault key (key-equivalent). A world-readable export bundle is a significant exposure. |
| M-2 | **Secret value returned as plain `String` in daemon response** | **Medium** | `daemon.rs:49-52`, `daemon.rs:241` — `Response::Secret { value: String }` carries the plaintext secret as a regular `String` in the serde transport. This String is not `Zeroizing`; it lives in the `serde_json` serialization buffer and the client's `BufReader` line buffer. The original `Zeroizing<String>` from `Vault::get_secret` is converted to a plain `String` at `daemon.rs:242` (`value.to_string()`). |
| M-3 | **`Vault::load_secrets()` deserializes entire secret store into non-zeroizing `HashMap<String, String>`** | **Medium** | `vault.rs:152-157` — The `SecretStore` wrapper `ZeroizeOnDrop` only covers the outer `String` (the JSON blob). Individual `HashMap` entries are standard `String`s. On drop, the `SecretStore` zeroizes its outer string, but the `serde_json` parse path and the `HashMap` key/value pairs are regular heap allocations that are freed but not zeroized. |
| M-4 | **`.session` key file is key-equivalent but not encrypted** | **Medium** | `session.rs:18-24` — The derived key is written as hex to `.session` at 0600. Anyone with read access to this file (root, same-UID, or a permissions bug) can open the vault without the passphrase. The daemon path avoids this file entirely, but the fallback (and Windows) path depends on it. |
| M-5 | **Argon2id parameters are fixed and not tunable** | **Medium** | `crypto.rs:13-15` — M_COST=64MB, T_COST=3, P_COST=1. These are reasonable for 2024-2026 but not configurable. As hardware advances, 64MB may become insufficient for resistance against GPU/ASIC attacks. No future-proofing mechanism exists. |

### Low / Informational

| # | Finding | Severity | Evidence |
|---|---------|----------|----------|
| L-1 | **Signal handler uses `libc::signal` (not `sigaction`)** | Low | `daemon.rs:350-353` — `libc::signal` has unspecified semantics on some Unix variants and may be reentrant. In practice on modern Linux/macOS this is fine, but `sigaction` is the POSIX-preferred interface. The handler is async-signal-safe (only flips an atomic), so the actual risk is minimal. |
| L-2 | **`daemon.log` has no permissions enforcement on rotation** | Low | `daemon.rs:593-598` — Log rotation renames to `.log.1` but doesn't enforce permissions on the new file. The daemon's stdout/stderr are redirected to the log via `Stdio::from(log)`, which inherits the `OpenOptions` defaults. Since the log is inside 0700 `.svault/`, traversal protection exists. |
| L-3 | **Passphrase entropy estimate does not model dictionary attacks** | Low | `passphrase.rs:9-14` — The `entropy_bits` function is `length × log2(pool)`. It will credit "correcthorsebatterystaple" with ~131 bits even though it's a known xkcd phrase. The `check()` function catches common words, but a determined attacker with a large phrase dictionary could find low-entropy passphrases that pass the floor. The ~50-bit floor is still meaningful against blind brute force. |
| L-4 | **Import path writes vault files with default permissions** | Low | `portable.rs:131-140` — `import_bundle_as` writes vault files (including `vault.enc`) with `std::fs::write` (default umask). The parent directory should be 0700 from `Vault::init`, but during import the directory is created with `create_dir_all` without explicit permissions. On the CLI path this is called before `Vault::init` fixes permissions, but on re-import the directory may persist with loose perms briefly. |
| L-5 | **No rate limiting on daemon Unlock attempts** | Info | `daemon.rs:162-191` — The `Unlock` handler validates the key against the vault, but there is no limit on failed Unlock attempts. An attacker with socket access could brute-force keys (though AES-256-GCM makes this impractical — each attempt requires a full decrypt-and-verify). |
| L-6 | **Recovery code is Argon2id-derived from a hex string** | Info | `recovery.rs:53` — The 160-bit code is hex-formatted (40 chars of `[0-9A-Fa-f]`), then fed through `VaultKey::derive()` with Argon2id. This is correct and the pool size is 16 chars, but the Argon2id overhead on a 40-char alphanumeric input is arguably unnecessary — the code already has 160 bits of entropy. Not a vulnerability; just defense-in-depth overkill. |

---

## Positive Security Properties Observed

1. **Client-side key derivation (finding #3) is correctly implemented.** `client.rs:43-46` opens the vault locally to derive and validate the key before sending it to the daemon. The passphrase never crosses the socket. A wrong passphrase fails locally.

2. **Peer-UID bond is correct on both Linux and macOS.** `daemon.rs:370-406` uses `SO_PEERCRED` on Linux and `getpeereid` on macOS. Both code paths return `false` on any syscall error, which is fail-closed. The comparison is against `geteuid()`, which is the correct check.

3. **Socket is born 0600 with no TOCTOU window.** `daemon.rs:556-560` — The umask is set to 0077 before `bind()` and restored after. An explicit `set_permissions(0600)` is also applied as a belt-and-suspenders measure.

4. **Mutex poisoning recovery is safe.** `daemon.rs:149-153` — `into_inner()` recovers the guard even after a panic. The comment correctly notes that the worst case is a stale `last_used` timestamp, never key corruption.

5. **Connection ceiling is enforced with a leak-proof `ConnGuard`.** `daemon.rs:112-117` — The guard decrements the atomic counter on drop (including during panic unwinding). The ceiling is checked before spawning a thread.

6. **Graceful shutdown zeroizes keys and cleans up.** `daemon.rs:346-363, 457-460` — SIGTERM/SIGINT flip an atomic, which triggers the accept loop to exit, clearing the store (zeroizing every `Held`) and removing socket/pid files.

7. **HMAC-SHA256 metadata integrity is verified with constant-time comparison.** `meta.rs:198-206` — The `constant_time_eq` function XORs all bytes and folds, preventing timing side-channels.

8. **AES-256-GCM with random nonces provides authenticated encryption.** `crypto.rs:47-61` — Each encryption uses `OsRng` to generate a unique 96-bit nonce. GCM provides both confidentiality and integrity. Wrong-key decryption is detected by the GCM tag.

9. **Recovery codes are 160 bits of randomness.** `recovery.rs:31-33` — Generated with `rand::thread_rng()`, well above any brute-force threshold.

10. **`.gitignore` self-healing prevents accidental commits of sensitive files.** `vault.rs:38-41`, `usage.rs:138-160` — Session, audit, and usage logs are always gitignored, and stale `.gitignore` files are repaired on first use.

11. **Export bundles are integrity-checked with SHA-256.** `portable.rs:28-38, 85-98` — Corrupted bundles are rejected.

12. **`cargo audit` CI gate catches known RustSec vulnerabilities.** `lint.yml:37-46` — Runs on every push/PR with `--deny warnings`.

13. **SLSA build-provenance attestation on release artifacts.** `release.yml:76-79` — Uses `actions/attest-build-provenance@v2` with `id-token: write` permission.

14. **SHA-256 checksums published alongside release binaries.** `release.yml:54-61` — Users can verify downloads.

15. **Comprehensive test coverage for security-critical paths.** 84 tests cover daemon protocol, policy enforcement, crypto roundtrips, tamper detection, permission checks, poison recovery, concurrent access, and export/import integrity.

---

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

### Architecture

The daemon is a single-threaded accept loop (`daemon.rs:409-461`) that spawns one `std::thread` per connection. Shared state is an `Arc<Mutex<HashMap<String, Held>>>` where each `Held` contains a `Zeroizing<[u8; 32]>` key, an `unlocked_at` timestamp, and a `last_used` timestamp.

**Security benefits over the file session:**
- Keys never touch disk — held only in memory
- Keys are zeroized on lock, auto-lock eviction, and shutdown
- No `.session` file exists while the daemon is up
- Auto-lock timers (idle + hard-max) provide automatic key eviction
- Peer-UID bond provides defense-in-depth beyond socket permissions
- Connection ceiling bounds resource consumption from same-UID processes
- Per-connection read timeout prevents stalled connections from pinning handler threads

### Remaining Risks

1. **The daemon serves secrets to any same-UID process with no policy gate.** This is the most significant risk. The `Get` handler at `daemon.rs:223-253` checks only that the vault is unlocked — it does not check caller identity, scope, reason, sensitivity tier, or rate limits. The audit log is not written by the daemon. This means:
   - Any same-UID process can enumerate unlocked vaults (via `Status` request) and retrieve any secret
   - The policy engine only applies to the `svault get` CLI command, not to direct socket access
   - An AI agent with same-UID access can bypass the policy engine entirely by talking to the socket directly

2. **The protocol is not authenticated or encrypted.** The socket is 0600 and peer-UID is checked, but once connected, there is no session authentication. A compromised same-UID process (or a process that gained same-UID via `sudo -u`) has full access.

3. **The thread-per-connection model is not bounded in memory.** While the connection count is capped, each thread holds a copy of the vault's 32-byte key during decryption. Under max load (512 concurrent connections), 16 KB of key material could exist simultaneously in thread stacks. This is negligible, but worth noting.

4. **No daemon authentication logging.** The daemon writes to `daemon.log` via stdout/stderr redirection but does not log individual requests, access attempts, or policy decisions. There is no way to audit who accessed what via the daemon.

### Permission Model Assessment

| File/Resource | Mode | Set By | Verified |
|---|---|---|---|
| `.svault/` | 0700 | `secfile::create_dir_owner_only` | Yes (test at `secfile.rs:79`) |
| `<vault>/` | 0700 | `secfile::create_dir_owner_only` | Yes |
| `daemon.sock` | 0600 | umask 0077 + explicit chmod | Yes (`daemon.rs:556-560`, `doctor` check at `daemon.rs:758`) |
| `daemon.pid` | 0600 | `OpenOptions::mode(0o600)` | Yes (`daemon.rs:470`) |
| `.session` | 0600 | `secfile::write_owner_only` | Yes (test at `secfile.rs:77`) |
| `recovery.enc` | 0600 | `secfile::write_owner_only` | Yes |
| `audit.log` | 0600 | `OpenOptions::mode(0o600)` | Yes (`audit.rs:88`) |
| `usage.log` | 0600 | `OpenOptions::mode(0o600)` | Yes (`usage.rs:181`) |
| Export bundle (CLI) | 0600 | `secfile::write_owner_only` | Yes (`main.rs:1134`) |
| Export bundle (TUI) | default umask | `std::fs::write` | **No** (`tui/mod.rs:813`) — **Bug M-1** |

### Auto-Lock Assessment

Auto-lock works correctly. The ticker runs every ~10 seconds (`daemon.rs:326`), evicts keys where `idle_secs >= idle_timeout OR age_secs >= max_unlocked` using `HashMap::retain`, which drops `Held` values (triggering `Zeroizing<[u8; 32]>::drop`). Defaults (15 min idle, 8 h hard-max) are sensible. Both are configurable via `.svault/config.yaml`.

### Same-UID Trust Model

The project documents that it is "not a sandbox against a hostile same-UID process" (`docs/security.md:29`). This boundary is **reasonable and clearly communicated**. On Unix, a same-UID process can always `ptrace` the daemon, read `/proc/<pid>/mem`, or send signals. The peer-UID check and 0600 socket provide defense-in-depth against *accidental* cross-user access and *opportunistic* low-effort attacks, but not against a determined same-UID adversary.

**The issue is not the same-UID trust model itself — it's that the policy engine, which is the product's headline feature, is entirely bypassable by any same-UID process.** A same-UID process doesn't need to exploit the daemon; it simply sends a well-formed JSON request.

---

## Infrastructure, Server & CI/CD Deployment Considerations

### Current limitations and improvements due to the daemon

**On Unix servers with the daemon:**
- **Significantly better than the file session.** No `.session` file on disk. Auto-lock ensures keys are evicted. Peer-UID check prevents cross-user access.
- **The daemon is per-project** (one per `.svault/` directory). On a server with many projects, multiple daemons may be running, each consuming memory and a thread pool.
- **No systemd integration.** The daemon uses `setsid()` to detach, but there is no systemd unit file, socket activation, or watchdog. On a server, the daemon could silently die without being restarted.

**On CI/CD pipelines:**
- The file session path is the only option on Windows CI runners (daemon is Unix-only).
- The `.session` file is key-equivalent. If a CI runner is shared or compromised, the cached key is accessible to any process on the runner.
- `svault get` with `--caller` and `--scope` is the intended CI path, but the self-asserted nature of these flags means the CI script author controls the audit trail, not the system.

**On Windows:**
- No daemon at all — falls back to the file session (`session.rs`).
- `icacls` ACL via `secfile.rs:49-58` is best-effort. It depends on the `USERNAME` environment variable and silently ignores failures. NTFS ACL inheritance may override the ACL in some configurations.
- The `icacls` approach does not handle domain accounts, special SIDs, or inherited deny ACEs correctly in all cases.

### Recommended safe usage patterns on Unix servers and CI today

1. **Always run the daemon** (`svault daemon start`) on Unix servers — avoid the `.session` file.
2. **Set aggressive auto-lock timers** for CI/CD: `idle_timeout_secs: 300`, `max_unlocked_secs: 3600`.
3. **Reduce `max_connections`** on small CI runners: 32 or 64.
4. **Use `svault lock --all`** at the end of every CI job to zeroize keys.
5. **Never share the Svault directory across users.** The entire security model assumes single-user ownership.
6. **Verify the daemon is running** (`svault daemon doctor`) in CI before secret access.

### Risks on Windows and non-Unix environments

- **No in-memory key protection.** The `.session` file is the only mechanism.
- **File permissions are best-effort.** The `icacls` ACL may be overridden by group policy or inherited permissions.
- **No auto-lock.** Without the daemon, the only way to clear the key is explicit `svault lock`.
- **High-security production infrastructure should not rely on Svault on Windows in its current state.**

### Recommendations for high-value secrets

- Treat Svault as a **developer convenience tool**, not a hardware security module.
- For production secrets (database passwords, cloud credentials), pair Svault with a proper secrets manager (HashiCorp Vault, AWS Secrets Manager, etc.).
- The recovery code mechanism is excellent for disaster recovery but should be stored in a separate system (offline, password manager) — never alongside the vault.

---

## Recommendations for Corporate Adoption

Prioritized and actionable, reflecting the 0.7.0 state:

1. **[CRITICAL] Enforce the policy engine at the daemon layer.** The daemon's `Get` handler must accept and validate caller/scope/reason, run the policy pipeline, and write audit entries — exactly as `cmd_get` does in `main.rs`. Without this, the daemon is a policy-free secret server. **This should block a 1.0.0 "stable" label.** (Issues #2/#5 on the roadmap.)

2. **[HIGH] Sign and verify the policy file.** Add an HMAC (keyed on the vault key or a dedicated policy key) to `svault.policy.yaml`. The daemon should refuse to load an unsigned or tampered policy file. This closes the upward-directory-search attack vector.

3. **[HIGH] Fix TUI export permissions (M-1).** Replace `std::fs::write` at `tui/mod.rs:813` with `secfile::write_owner_only`. One-line fix.

4. **[MEDIUM] Add daemon access logging.** The daemon should write audit entries for every `Get` request (allowed or denied), including the peer UID, vault, secret name, and whether a policy was enforced. Without this, there is no forensic trail for daemon-mediated access.

5. **[MEDIUM] Protect the transport secret with `Zeroizing`.** The `Response::Secret { value }` should carry a `Zeroizing<String>` or the daemon should zeroize the response buffer after writing. Currently the serde/transport path creates plaintext `String` copies that are not zeroized.

6. **[MEDIUM] Add systemd support.** Provide a systemd unit file template for server deployments. This ensures the daemon is supervised, restarted on failure, and properly shut down with the system.

7. **[LOW] Switch signal handler to `sigaction`.** Replace `libc::signal` with `libc::sigaction` for correct signal semantics across all Unix variants.

8. **[LOW] Add Argon2id parameter tunability.** Allow `m_cost`, `t_cost`, and `p_cost` to be configured in `.svault/config.yaml` for forward-compatibility with stronger hardware.

9. **[INFO] Consider encrypting the `.session` file.** Even at 0600, the key-equivalent `.session` file is a risk on shared or compromised systems. Encrypting it under a machine-specific key (e.g., keychain-derived) would add a layer of protection.

**Should policy enforcement (#2/#5) block a 1.0.0 "stable" label?**

**Yes.** The current state — where the daemon serves secrets without any policy enforcement — means that the product's core value proposition ("AI-aware secret access layer — enforces structured requests") is not actually enforced at the only layer that matters for continuous access (the daemon). The CLI `svault get` path enforces policy, but the daemon path does not, and the daemon is the recommended production path on Unix. Shipping 1.0.0 without daemon-enforced policy would mislead adopters about the actual security boundary.

---

## Overall Risk Assessment by Context

| Context | Risk | Notes |
|---|---|---|
| Personal use (Unix + daemon) | **Low** | Strong security posture. Same-UID trust model is acceptable for personal use. Policy gap is irrelevant if no agents share the UID. |
| Small team (Unix + daemon, single server) | **Medium** | Peer-UID + 0600 socket protect across users. Same-user agents can bypass policy via socket. Acceptable if all same-UID agents are trusted. |
| Corporate CI/CD with AI agents on Unix | **High** | Self-asserted caller/scope, unsigned policy, no daemon enforcement. CI scripts control their own audit trail. The daemon can be used to serve secrets without any policy check. Must enforce policy at daemon layer before trusting this in CI. |
| High-security production infrastructure | **High** | Key-equivalent files at rest, no HSM integration, no daemon audit logging. Should be paired with a real secrets manager. The recovery code mechanism is the strongest safety net. |
| Mixed Windows environments | **High** | No daemon, icacls best-effort, file session with key-equivalent .session. No auto-lock. Not recommended for sensitive secrets on Windows today. |
| Developer workstations (Unix + daemon) | **Low-Medium** | Good security for local development. Auto-lock and peer-UID provide meaningful protection. The policy gap matters only if hostile agents run as the same user. |

---

## References

### Key source files analyzed
- `src/daemon.rs` — Unix daemon implementation (1,408 lines)
- `src/client.rs` — Client-side key derivation and daemon routing (138 lines)
- `src/policy.rs` — Policy engine (569 lines)
- `src/crypto.rs` — AES-256-GCM + Argon2id (159 lines)
- `src/secfile.rs` — Owner-only file/dir writes (89 lines)
- `src/session.rs` — File-based session (126 lines)
- `src/vault.rs` — Vault operations (386 lines)
- `src/recovery.rs` — Recovery code mechanism (181 lines)
- `src/meta.rs` — HMAC-signed metadata (225 lines)
- `src/main.rs` — CLI routing and policy integration (1,412 lines)
- `src/passphrase.rs` — Entropy estimation (131 lines)
- `src/portable.rs` — Export/import bundles (314 lines)
- `src/audit.rs` — Append-only audit log (203 lines)
- `src/usage.rs` — Usage timeline (296 lines)
- `src/config.rs` — Daemon configuration (91 lines)
- `src/tui/mod.rs` — TUI application logic
- `src/tui/ui.rs` — TUI rendering

### CI/CD workflows
- `.github/workflows/lint.yml` — fmt + clippy + cargo-audit gate
- `.github/workflows/test.yml` — Reusable test job (Ubuntu, macOS, Windows, Fedora)
- `.github/workflows/release.yml` — Multi-target build + SHA-256 checksums + SLSA provenance

### Documentation
- `docs/security.md` — Security model and threat model notes
- `docs/daemon.md` — Daemon architecture and usage
- `docs/policy-engine.md` — Policy engine design
- `docs/recovery.md` — Recovery mechanism
- `Cargo.toml` — Version 0.7.0, dependency tree

### External resources consulted
- Rust `aes-gcm` crate documentation (v0.10)
- Rust `argon2` crate documentation (v0.5)
- Rust `zeroize` crate documentation (v1)
- OWASP Application Security Verification Standard (ASVS) 4.0
- NIST SP 800-63B (memorized secrets)
