- 2026-04-18 initial audit verification (all fixed except H8) - 2026-05-01 audit with 8 new findings (B1-B4, I1-I6) - Plan 4: Security Blocker Fixes implementation plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9.8 KiB
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/<pid>/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:
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<u8> 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 |