# Relicario Security Audit — 2026-05-01 Scope: full project audit (not a PR diff). Covers crypto correctness, protocol gaps, implementation reality vs. plans, and a roadmap toward third-party auditability. --- ## Section 1 — Security Findings ### Finding 1 — Backup KDF missing NFC normalization **`crates/relicario-core/src/backup.rs:303-312`** · Severity: **Medium** `derive_backup_key` passes raw passphrase bytes to Argon2id. The main vault KDF in `crypto.rs` uses `u64_be(len) || nfc_passphrase || u64_be(32) || image_secret`. The backup KDF has neither NFC normalization nor the length-prefix construction. **Exploit:** User creates a backup on macOS (NFD normalization) and restores on Linux (NFC). The Argon2id input differs → wrong key → unrestorable backup. Affects any non-ASCII passphrase (`"Crêpe-7"`, `"café"`, accented chars). **Fix:** Factor out `normalize_passphrase()` and use it in both `derive_master_key` and `derive_backup_key`. --- ### Finding 2 — Commit message injection via item titles **`crates/relicario-cli/src/main.rs:565, 899-901, 1110, 1327`** · Severity: **Medium** Item titles (arbitrary user UTF-8) are embedded directly into `-m` commit message strings via `format!("add: {} ({})", item.title, ...)`. `git_command` uses `Command::args()` (no shell), so shell injection into `git add` is blocked — but newlines in titles produce malformed multi-line commit messages that corrupt git log parsers. **Fix:** Strip control characters from titles before embedding in commit messages, or omit the title from the `-m` format entirely and use only the item ID. --- ### Finding 3 — WASM `generate_device_keypair` crosses private key bytes to JS **`crates/relicario-wasm/src/lib.rs:215-227`** · Severity: **Medium** Returns `{ "private_key_base64": "..." }` as a `JsValue`. The ed25519 private key lives in the JS heap with no `Zeroizing` protection. The vault master key is protected behind an opaque `SessionHandle` and never crosses to JS — the device key has no such protection. **Exploit:** Any JS running in the extension service worker context (compromised dependency, content script escalation) that can intercept the return value gets the raw device key. **Fix:** Never return the private key to JS. Expose only a `sign(handle, data) → signature` API; perform the signing in Rust. --- ### Finding 4 — Test env vars ship in production binary **`crates/relicario-cli/src/main.rs:445-446, 421-423, 1425-1426`** · Severity: **Medium** `RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE` are checked in production code (not `#[cfg(test)]`). When set, they bypass the interactive TTY prompt. **Exploit:** On Linux, `/proc//environ` exposes the passphrase in cleartext to same-UID processes. Shell history captures `RELICARIO_TEST_PASSPHRASE=mysecret relicario unlock ...`. **Fix:** Gate behind `#[cfg(test)]` or a `--features testing` build profile. --- ### Finding 5 — `AttachmentId` truncated to 64 bits of SHA-256 **`crates/relicario-core/src/ids.rs:52-57`** · Severity: **Medium** `AttachmentId::from_plaintext` takes `&digest[..8]` (8 bytes = 64 bits). Standard content-addressed stores use ≥128 bits. With 64 bits, an attacker who can supply attachment content can find a second-preimage collision with ~2^32 work, causing a crafted attachment to silently overwrite an existing one on disk. **Fix:** Change `&digest[..8]` → `&digest[..16]` (128 bits). No migration needed for existing vaults since only new attachments are affected. --- ### Finding 6 — `get_field_history` re-parses item JSON from JS heap **`crates/relicario-wasm/src/lib.rs:232-265`** · Severity: **Medium** Returns all historical `Password`/`Concealed` values as plaintext `JsValue`. The values are regular `String` allocations with no `Zeroizing` wrapper before serialization into `serde_json::Value`. **Fix:** Architectural — document that the caller must treat the return value as sensitive. For strong hygiene: do all history display in Rust, never returning password history bytes to JS. --- ### Finding 7 — Device key system is non-functional as a security control **`crates/relicario-cli/src/main.rs:2151-2221`** · Severity: **High** `device add/list/revoke` and `generate_device_keypair` exist, but **no code anywhere signs git commits with device keys**, and **no code verifies device signatures**. `devices.json` is plaintext in the repo and unauthenticated by the vault. **Exploit:** Users believe "device revocation" prevents unauthorized access after a device is stolen/compromised. It does nothing. A stolen device continues to have full vault access via its git remote credentials regardless of revocation. **Fix:** Either (a) implement commit signing + server-side pre-receive hook verification, or (b) remove the `device` subcommands and document that access control is SSH-key-level only. --- ### Finding 8 — Path traversal on backup restore **`crates/relicario-cli/src/main.rs:1619-1626`** · Severity: **Medium** During restore, item/attachment IDs from the decrypted backup JSON are used directly as path components with no format validation. IDs are AEAD-authenticated but a user restoring from a crafted `.relbak` with a known passphrase would execute arbitrary path writes. **Exploit (social engineering):** Attacker provides a `.relbak` with item ID `../../.bashrc` → restore overwrites `~/.bashrc`. **Fix:** ```rust ensure!(id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()), "invalid id in backup"); ``` --- ## Section 2 — Implementation Status | Feature | Status | Notes | |---|---|---| | Two-factor decrypt (passphrase + image_secret) | ✅ Implemented | Full crypto pipeline, NFC passphrase, Argon2id m=64MiB t=3 p=4 | | imgsecret embed | ✅ Implemented | DCT QIM, QUANT_STEP=50, central 70%, 5-50 redundant copies | | imgsecret extract + crop recovery | ✅ Implemented | Majority voting ≥60%, 4 crop search strategies | | Manifest browse (schema v2) | ✅ Implemented | Encrypted with master key, search(), title/type/tags/icon | | Vault CRUD (init/add/edit/rm/trash/restore/purge) | ✅ Implemented | All 7 item types fully handled | | CLI `init` | ✅ Implemented | zxcvbn ≥3 gate, image embed, Argon2id params, git init | | CLI `add` / `edit` | ✅ Implemented | All 7 types, TOTP QR decode via rqrr, field history capture | | CLI `generate` | ✅ Implemented | Random (rejection-sampled) + BIP39, uses vault defaults | | CLI `sync` | ✅ Implemented | `git pull --rebase && git push` | | CLI `backup export/restore` | ✅ Implemented | Plan 3A: zstd+AEAD container, optional image + git bundle | | CLI `import lastpass` | ✅ Implemented | Plan 3B: CSV validation, Login + SecureNote + TOTP mapping | | WASM bindings (all item/manifest/settings) | ✅ Implemented | Complete symmetric set | | WASM session handle (opaque master key) | ✅ Implemented | Key never crosses WASM boundary | | WASM attachment, generator, TOTP, backup, import | ✅ Implemented | All wired | | Field history tracking + CLI `history` | ✅ Implemented | Password/Concealed/TOTP history, prune policies | | Trash + retention | ✅ Implemented | `trash list/empty`, TrashRetention window | | Attachments (CLI + WASM) | ✅ Implemented | File-level AEAD, cap enforcement, Document type | | Settings / VaultSettings | ✅ Implemented | All retention + generator + cap fields, CLI subcommands | | Device keys (add/list/revoke) | ⚠️ Partial | Key gen + persistence only — **no signing, no verification** (Finding 7) | | Per-vault total attachment cap | ⚠️ Partial | Cap defined in settings, per-attachment enforced — per-vault total bytes not checked | | Browser extension UI | ⚠️ Partial | WASM surface complete; extension TypeScript/HTML is a separate repo | | Recovery QR | ❌ Plan-only | Spec written; no `recovery_qr.rs` module exists | | Password coloring | ❌ Plan-only | Spec written; no implementation | | Passphrase rotation | ❌ Deferred | Explicitly back-burnered | | Pre-v0.3.0 audit walk | ❌ Not started | Listed as pending before v0.3.0 tag | | HOTP counter persistence | ❌ Bug | `Hotp { counter }` never incremented/saved — HOTP desynchronizes immediately | --- ## Section 3 — Path to Certifiable Safety ### Blockers — must fix before any real use | # | Item | |---|---| | B1 | **Device key system is security theater** — implement signing or remove the commands. This is the most dangerous finding because it misleads users about their security posture. | | B2 | **Backup KDF NFC normalization** — one-line fix; data loss risk for non-ASCII passphrases. | | B3 | **Test env vars in production binary** — gate with `#[cfg(test)]`. Exposes passphrase via `/proc`. | | B4 | **Path traversal on restore** — two-line ID validation before any `fs::write`. | ### Important — fix before third-party audit | # | Item | |---|---| | I1 | Sanitize item titles before embedding in commit messages | | I2 | `AttachmentId`: `&digest[..8]` → `&digest[..16]` (128-bit collision resistance) | | I3 | Enforce per-vault total attachment bytes cap (already defined, never checked) | | I4 | Document manifest integrity model: AEAD protects against silent modification, but item deletion is only detectable via git history | | I5 | Stop crossing device private key bytes to JS (prerequisite for B1 if signing is implemented) | | I6 | Fix HOTP counter: increment + re-save on each `totp get`, or disable HOTP and return an error | ### Nice-to-have — audit-friendliness | # | Item | |---|---| | N1 | Wrap `nfc_passphrase: Vec` in `Zeroizing` in `derive_master_key` | | N2 | `cargo audit` in CI | | N3 | Validate Argon2id params on vault load — warn if below production minimums | | N4 | Broaden steganography recompression tests to use ImageMagick/libjpeg-turbo (not just the `image` crate) | | N5 | Consider machine-readable audit log encrypted alongside the vault |