Files
relicario/docs/superpowers/audits/2026-05-01-security-audit.md
adlee-was-taken 71d51c0bea docs: add security audits and Plan 4 for blocker fixes
- 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>
2026-05-02 00:42:17 -04:00

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