# 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:**
```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<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 |
