Files
relicario/crates/relicario-cli/ARCHITECTURE.md
adlee-was-taken c66fd520f8 docs(arch): per-codebase ARCHITECTURE.md + cross-codebase overview
Strategic-depth architecture documentation, the kind that's hard to
recover by reading code: invariants, multi-file flows, design rationale,
gotchas. Goal is to cut the token cost for future Claude sessions.

Four new docs (2091 lines total):

- crates/relicario-core/ARCHITECTURE.md (514 lines) — bytes-in/bytes-out
  boundary, 24 verified invariants (VERSION_BYTE=0x02, length-prefixed
  KDF input, NFC normalization, content-addressed AttachmentId, history-
  tracked field kinds, 60% imgsecret confidence floor, MAX_DIMENSION=
  10000, etc.), 7 multi-module flows, 16 non-obvious gotchas (QUANT_STEP=
  50, central-70%-embed, BIP39-128bit-then-truncate, Steam alphabet
  rationale).

- crates/relicario-cli/ARCHITECTURE.md (539 lines) — module map for the
  three source files; the cmd_add/cmd_edit per-type helper pattern (post-
  2026-04-27 refactor); the hardened-git invariant (Command::new("git")
  is gated to helpers.rs:46); the five history synthetic keys; the env-
  var escape-hatch policy; cmd_generate's two-mode design (no-unlock
  outside vault, unlock-and-read-defaults inside).

- extension/ARCHITECTURE.md (831 lines) — five-bundle structure (popup,
  vault, setup, content, service-worker); SW-as-crypto-fortress model;
  capability-set-or-silent-rejection contract; vault-tab-as-popup-class
  router parity (commit a7dbf35); origin TOFU flow; setup state machine;
  test-vs-build gap.

- docs/architecture/overview.md (207 lines) — cross-codebase entry point.
  How the three codebases fit together, the four versioned wire formats
  between them (core→WASM ABI, SW chrome.runtime protocol, vault on-disk
  layout, GitHost API), per-codebase secret residency table, build
  matrix, conventions that span all three.

Specs in docs/superpowers/specs/ remain as historical decision artifacts
("why we chose this") — the new arch docs are the source of truth for
"what is" current invariants and flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:41:26 -04:00

29 KiB
Raw Permalink Blame History

Architecture: relicario-cli

What this crate is for

The relicario binary is the platform layer for relicario-core: it adds filesystem layout, a hardened git shell-out, interactive rpassword prompts, clipboard handoff, and a clap-based command surface. The crate has two design roles. First, it is the developer / power-user surface that exposes everything the core can do (every ItemCore variant, every VaultSettings knob, history inspection, device key management). Second, it is the only working interface during disaster recovery — the extension may be uninstalled, the device may be new — so it intentionally maintains feature parity with the extension's vault tab. It deliberately shells out to git rather than depending on libgit2 / gitoxide; this keeps the dep tree slim, lets the user override git config locally, and lets recovery debugging happen with familiar tooling.

Module map

The crate is three files of source and a tests/ directory. Each source file has one job.

  • src/main.rs (main.rs:1-1719) — clap surface plus every command handler. Internal structure: a top-level Cli / Commands enum (main.rs:13-275), a flat dispatcher match in main() (main.rs:277-303), per-command handlers named cmd_<verb>, and a layer of per-type item helpers (build_<type>_item for cmd_add, edit_<type> for cmd_edit). The per-type split is recent: commit 3f0f5b1 extracted ~217-line match arms in cmd_add and cmd_edit into focused functions, one per ItemCore variant, so each builder/editor reads top-to-bottom and can be tested through the same integration paths. Owns all clap argument parsing, all interactive prompts (prompt, prompt_optional, prompt_keep, prompt_keep_opt, prompt_yesno, prompt_secret), and the shared commit_paths helper that is the single chokepoint for git commits during vault mutations.

  • src/session.rs (session.rs:1-152) — UnlockedVault lifecycle. Holds the derived master key in Zeroizing<[u8; 32]> for one CLI invocation; the key wipes via Zeroize on scope exit (session.rs:22-25). Owns the unlock_interactive flow (vault root walk → salt read → params read → reference image extract → passphrase prompt → KDF) at session.rs:33-59, the typed load_* / save_* accessors for Item / Manifest / VaultSettings, the read_salt / read_params helpers, the RELICARIO_IMAGE lookup, and atomic_write (session.rs:144-151) which every disk write to a vault file goes through. Owns the env-var escape hatches RELICARIO_TEST_PASSPHRASE (session.rs:42) and RELICARIO_IMAGE (session.rs:125) that integration tests use to bypass the TTY.

  • src/helpers.rs (helpers.rs:1-101) — pure, no-state plumbing: find_vault_dir_from (helpers.rs:14-28) walks up parent directories looking for a .relicario/ marker; vault_dir and relicario_dir wrap it for cwd-rooted callers; git_command (helpers.rs:45-55) is the hardened-git factory that every git invocation in the crate (production code, not tests) goes through; iso8601 (helpers.rs:60-64) formats Unix seconds for human-readable output (audit M11). The hardening is load-bearing — see Invariants & Gotchas below.

Invariants & contracts

These are the load-bearing rules the crate relies on. Each has been verified in code; cite the line if you change it.

  • Every vault-mutating command unlocks via UnlockedVault. The struct holds the master key in Zeroizing<[u8; 32]> and drops via Zeroize on scope exit (session.rs:22-25). No command bypasses this except cmd_generate outside a vault dir and cmd_init (which derives the key inline before there is a vault to unlock).

  • Every git invocation in production code goes through helpers::git_command. A grep for Command::new("git") outside helpers.rs finds zero hits in src/; the only other match is in tests/edit_and_history.rs:18, which is test-side verification of the git log and is exempt by design. git_command injects core.hooksPath=/dev/null, commit.gpgsign=false, and core.editor=true via -c flags (helpers.rs:48-52). Direct Command::new("git") would bypass the hardening — don't.

  • Every file write to a vault file uses atomic_write. atomic_write (session.rs:144-151) writes <path>.tmp then renames over <path>; a partial write never appears as the live file. All UnlockedVault::save_* helpers route through it. (cmd_init writes pre-creation files via fs::write at main.rs:373-393; that path doesn't need atomicity because the vault doesn't exist yet — failure leaves a half-built vault that the next run rejects via relicario_dir.exists() at main.rs:326.)

  • Every commit during a mutating command uses commit_paths. commit_paths (main.rs:767-775) does git add <paths> && git commit -m <msg> through the hardened wrapper. Commit message convention is <verb>: <title> (<id>)add:, edit:, trash:, restore:, purge:, attach:, detach:, settings: update, device: add <name>, device: revoke <name>, init: new relicario vault (format v2), trash empty: purged N item(s). cmd_purge and cmd_trash_empty and cmd_device use git_command directly (not commit_paths) because they need a slightly different add/commit pattern; they still go through the hardened wrapper.

  • cmd_generate is the only command that runs without unlock — and only when invoked outside a vault directory. Inside a vault, cmd_generate unlocks to read settings.generator_defaults (main.rs:1440-1445); explicit flags override the stored defaults. This is why the smoke-test cargo run -p relicario-cli -- generate --length 32 works without any setup.

  • Item IDs are minted by core. The CLI never constructs an ItemId directly; Item::new (called inside every build_*_item) does it via relicario-core::ids::new_item_id. ItemIds are 8-char hex.

  • Manifest is always saved last. Within a single command, the order is: write item file → mutate manifest → save manifest → commit. If the process dies between step 1 and step 3, the next run sees an item file with no manifest entry; cmd_status / cmd_list ignore it because they read the manifest, not the directory. (Recovery would manually re-add to surface it.)

  • Vault root is always discovered, never assumed to be cwd. helpers::vault_dir walks up from cwd looking for .relicario/, so any command run from a subdirectory of the vault works (verified by vault_detection.rs:23-40). v1 vaults using .idfoto/ are naturally rejected because they don't contain .relicario/ — no compat shim needed (vault_detection.rs:42-59).

  • prompt_secret reads RELICARIO_TEST_ITEM_SECRET before falling back to rpassword. This is the only way integration tests can drive the per-item secret prompts (Login password, Card number, TOTP secret rotation, Key material) without a real TTY. The check is at main.rs:308-313.

Key flows

Vault init (cmd_init, main.rs:315-418)

  1. Refuse if .relicario/ already exists (main.rs:326-328).
  2. Read passphrase twice (or once via RELICARIO_TEST_PASSPHRASE); confirm they match; run validate_passphrase_strength (zxcvbn-backed) and bail with audit-H3 message on weak input (main.rs:331-348).
  3. Generate a 32-byte random image_secret via OsRng, embed it into the carrier JPEG via imgsecret::embed, write the stego output to --output (main.rs:351-360).
  4. Generate a 32-byte salt and pin KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 } (production-grade) at main.rs:363-365.
  5. derive_master_key(passphrase, image_secret, salt, params)Zeroizing<[u8;32]> (main.rs:368).
  6. Create .relicario/, items/, attachments/ dirs; write .relicario/{salt, params.json, devices.json}; encrypt and write manifest.enc (empty Manifest::new()) and settings.enc (VaultSettings::default()) (main.rs:370-393).
  7. Write .gitignore listing the reference image filename (so the second factor never accidentally ends up in git) (main.rs:396-400).
  8. git init then initial commit init: new relicario vault (format v2) via git_command (main.rs:403-412). Note the initial commit does NOT go through commit_paths — it precedes the existence of an UnlockedVault, so the path list is hand-spelled.

Vault unlock (UnlockedVault::unlock_interactive, session.rs:33-59)

  1. vault_dir() walks up from cwd to find .relicario/; bails with the "run relicario init first" message on miss (helpers.rs:21-26).
  2. read_salt reads .relicario/salt (32 bytes; rejects any other length).
  3. read_params deserializes .relicario/params.json and extracts the nested kdf sub-object as KdfParams (session.rs:110-121). The nested shape exists because params.json also stores format_version, aead, and salt_path for forward-compat probing.
  4. get_image_path honours RELICARIO_IMAGE, then a <vault>/reference.jpg convention, then prompts (session.rs:124-140).
  5. Read the reference image bytes; imgsecret::extract runs the DCT majority-vote decode to recover the 32-byte image secret (session.rs:38-40).
  6. Read the passphrase via RELICARIO_TEST_PASSPHRASE or rpassword (session.rs:42-49).
  7. derive_master_key produces the master key; UnlockedVault { root, master_key } is returned and lives until the command function returns.

Item add (cmd_add, main.rs:419-456)

  1. Unlock the vault and load the manifest.
  2. Match on the AddKind variant and dispatch to the matching build_<type>_item helper (main.rs:423-438). Seven variants → seven builders; only build_document_item takes &UnlockedVault because it needs attachment_caps and writes the encrypted blob alongside the item.
  3. The builder returns a fully-populated Item (with title, group, tags, favorite-flag, primary attachment if any).
  4. Common wrap-up: vault.save_item(&item), manifest.upsert(&item), vault.save_manifest(&manifest).
  5. Build the path list — items/<id>.enc, manifest.enc, plus one attachments/<id>/<aid>.enc per attachment — and call commit_paths with message add: <title> (<id>) (main.rs:444-452).

Item edit (cmd_edit, main.rs:938-977)

  1. Unlock, load manifest, resolve query → item id, load the item.
  2. Universally-editable fields (title, group, tags) are prompted via prompt_keep / prompt_keep_opt first; blank input keeps the current value (main.rs:952-956).
  3. Borrow &mut item.field_history once into a local history binding (main.rs:958), then match on &mut item.core and dispatch to the per-type edit_<type> helper (main.rs:959-967). The history-tracking editors (edit_login, edit_secure_note, edit_card, edit_key, edit_totp) take &mut FieldHistory; the others (edit_identity, edit_document_message) don't.
  4. Each editor that mutates a tracked secret calls push_history(history, "<key>", old_value) (main.rs:1095-1109) — see the History flow below for the synthetic-key convention.
  5. item.modified = now_unix(), save, upsert manifest, commit edit: <title> (<id>).

edit_document_message (main.rs:1050-1052) just prints "use attach / extract instead" — Document items can't be field-edited; they're attachment-shaped.

The FieldHistory type alias (main.rs:983-986) is purely cosmetic; it exists so the editor signatures don't have to spell out the full HashMap<FieldId, Vec<FieldHistoryEntry>>.

History capture and view (push_history + cmd_history)

push_history (main.rs:1095-1109) records an old value under a synthetic FieldId(format!("core:{key}")). The core: prefix namespaces these keys so they can never collide with real custom-field UUIDs from the typed-item custom-fields work. The keys used in the codebase are:

  • core:login_password (main.rs:998)
  • core:secure_note_body (main.rs:1012)
  • core:card_number (main.rs:1031)
  • core:key_material (main.rs:1045)
  • core:totp_secret (main.rs:1063)

cmd_history (main.rs:1111-1159) reads item.field_history, sorts the keys, strips the core: prefix for display, and prints each entry list masked or revealed depending on --show. The --field <name> filter matches against either the stripped name (login_password) or the raw key (core:login_password) so both forms work (main.rs:1126-1129). The relicario history bank --field totp_secret form is what edit_and_history.rs exercises.

Trash & purge (cmd_rm / cmd_restore / cmd_purge / cmd_trash_empty)

  • cmd_rm (main.rs:1161-1176) calls Item::soft_delete() (sets trashed_at), saves, upserts manifest, commits trash:.
  • cmd_restore (main.rs:1178-1193) is the inverse: Item::restore(), same wrap-up, commit restore:.
  • cmd_purge (main.rs:1220-1237) calls purge_item (main.rs:1197-1218) which removes the item file, the attachment dir, the manifest entry, and git rm -rf --ignore-unmatchs the paths. Then a single git add manifest.enc + commit purge: <title> (<id>).
  • cmd_trash_empty (main.rs:1246-1282) is the only multi-item mutating command. It loads settings once, iterates all items past their trash_retention window, calls purge_item for each, then does a single git add manifest.enc + commit trash empty: purged N item(s). The single-unlock-per-batch shape was the fix in commit b5015b3 — the earlier version re-prompted for the passphrase per item.

Attach / detach / extract

  • cmd_attach (main.rs:1283-1339) loads attachment_caps from settings and rejects if the item has hit per_item_max_count. encrypt_attachment enforces per_attachment_max_bytes. The encrypted blob lands at attachments/<item_id>/<aid>.enc; the aid is content-addressed by core. Commit message: attach: <file> → <title> (<id>).
  • cmd_detach (main.rs:1376-1424, added in 3f0f5b1) removes one attachment from the item, deletes the encrypted blob, rewrites the item. Refuses if the target aid is a Document item's primary_attachment (main.rs:1392-1400) — that would orphan the item; use purge instead. Commit message: detach: <filename> from <title> (<id>).
  • cmd_extract (main.rs:1354-1375) decrypts the blob and writes the plaintext to --out or to <filename> in cwd. Read-only: no commit, no state mutation.
  • cmd_attachments (main.rs:1341-1352) lists aid, size, mime, filename — read-only.

Generate (cmd_generate, main.rs:1426-1489)

Has two distinct modes:

  • Outside a vaultvault_dir() returns Err; vault_defaults stays None; defaults are hard-coded (length: 20, symbols: SafeOnly, words: 5, separator: " ", Capitalization::Lower). No unlock prompt.
  • Inside a vaultvault_dir() succeeds; full unlock; load settings.generator_defaults. Explicit flags override the stored defaults field-by-field. --bip39 flips mode; absent that flag, the mode is whatever the stored default is. Tests: settings.rs::generate_uses_vault_default_length (length-tracking) and basic_flows.rs::generate_random_and_bip39 (no-vault smoke).

The two-mode shape is deliberate (see Gotchas) and is why cmd_generate is the only command outside cmd_init that touches helpers::vault_dir() directly instead of going through UnlockedVault::unlock_interactive().

Sync (cmd_sync, main.rs:1582-1590)

git pull --rebase then git push, both via the hardened wrapper. No unlock — sync moves opaque ciphertext, the master key is never needed. This is the only command that fails on conflict; it doesn't try to resolve. Resolution happens manually in the user's git tooling.

Status (cmd_status, main.rs:1592-1631, added in 3f0f5b1)

Unlocks; loads manifest; counts items (active vs trashed), attachments (count + total bytes), devices (parsed from devices.json); shells out to git log -1 --pretty=format:%h %s for the last-commit summary line. All read-only — no commit, no state change.

Device management (cmd_device, main.rs:1632-1702)

Add: generate ed25519 keypair via OsRng, append {name, public_key} to .relicario/devices.json, write the secret signing key to <config_dir>/relicario/devices/<name>.key with 0o600 on Unix, commit device: add <name>. List: print name pubkey_hex. Revoke: filter by name, rewrite devices.json, commit device: revoke <name>. Note that device keys are kept entirely separate from the KDF (passphrase × image stays unchanged across device add/revoke), as per the design spec.

Backup-passphrase-style commands (none yet)

The import / export / import-lastpass commands described in docs/superpowers/specs/2026-04-27-relicario-import-export-design.md are not yet implemented. When they land they'll fit in the dispatcher (main.rs:279-302) alongside Sync and Status. Don't add stubs here until that work begins.

Cross-cutting concerns

  • Error model. Every cmd_* returns anyhow::Result<()>. Core errors bubble up through ? from RelicarioError. Per-step context is added via with_context(|| ...) chains, e.g. format!("failed to read {}", path.display()). AEAD authentication failures intentionally surface as the ambiguous "wrong passphrase or corrupt vault" message from core — the CLI does not differentiate. clap argument errors are produced by clap (e.g., --days and --forever together fail at the SettingsAction::TrashRetention arm in main.rs:1504-1510).

  • Atomicity. Every disk write to a vault file goes through session.rs::atomic_write (session.rs:144-151): write <path>.tmp, then rename over <path>. Manifest is the single source of truth and is always written last in any multi-file operation, so a process kill between item-write and manifest-write leaves an orphan item file (which doesn't appear in list/status) but never a manifest pointing to a missing file.

  • Git history as audit log. Per-action commits, never amended, never squashed. The verb prefix on commit subjects (add:, edit:, trash:, restore:, purge:, attach:, detach:, settings:, device:, init:) makes git log --oneline a literal audit trail. Tests verify this by greping git log directly (e.g., edit_and_history.rs:18-22).

  • Where secrets live.

    • Master key — UnlockedVault.master_key: Zeroizing<[u8; 32]> (session.rs:24). Wipes on drop.
    • Image secret — Zeroizing<[u8; 32]>, lives only inside unlock_interactive until the KDF call (session.rs:40).
    • Passphrase — Zeroizing<String> from rpassword::prompt_password or the env var (session.rs:42-49, main.rs:333-342).
    • Item secrets — Zeroizing<String> for Login.password, Card.number, Card.cvv, Card.pin, Key.key_material, SecureNote.body, and Zeroizing<Vec<u8>> for TotpCore.config.secret (decoded from base32). All flow through core types.
    • Clipboard copy — Zeroizing<String> cloned into the detached 30s auto-clear thread (main.rs:873-889).
  • Test escape hatches. Three env vars exist for integration tests; all are read at exactly one site each:

    • RELICARIO_TEST_PASSPHRASEsession.rs:42 (unlock) and main.rs:333,338 (init).
    • RELICARIO_IMAGEsession.rs:125 (image path resolution).
    • RELICARIO_TEST_ITEM_SECRETmain.rs:309 (prompt_secret only). None of them have a production fall-through; absent the var, the code always prompts. They are safe in production binaries because the user would have to set them explicitly.
  • Generate-without-unlock is intentional. It is NOT an oversight. relicario generate --length 32 is the documented smoke test (see the repo's CLAUDE.md) and works as a standalone CSPRNG password generator outside any vault. Inside a vault it does require unlock — see Gotchas.

Test architecture

All tests are integration tests; there are no #[cfg(test)] modules in src/main.rs or src/session.rs. helpers.rs has four unit tests (helpers.rs:67-100) that exercise vault-dir walking and iso8601 formatting in isolation. Everything else is tests/.

  • tests/common/mod.rs (117 lines) — the harness. TestVault::init() spins up a fresh TempDir, generates a 400×300 JPEG via make_test_jpeg() (deterministic noise; no binary fixtures), runs relicario init --image carrier.jpg --output reference.jpg with RELICARIO_TEST_PASSPHRASE set, and stashes the passphrase + reference image path on the struct. run and run_with_input are the two ways to invoke the binary against the test vault: both inherit RELICARIO_IMAGE + RELICARIO_TEST_PASSPHRASE; the latter pipes extra newlines into stdin (used for interactive prompts that aren't rpassword-driven). The note at the top warns Task 23 implementers about the new-item-password rpassword path; the fix landed as RELICARIO_TEST_ITEM_SECRET in commit 20350d5.

  • tests/basic_flows.rs (136 lines) — covers the init layout (.relicario/{salt,params.json,devices.json}, manifest.enc, settings.enc, reference.jpg, .gitignore, .git); the params.json v2 shape; add login + list; get masking semantics (with and without --show); the rm/restore/purge cycle including list --trashed; and the two-mode generate smoke (random length + bip39 word count) run outside a vault.

  • tests/edit_and_history.rs (191 lines) — drives edit end-to-end by piping stdin lines (blank to keep, y to confirm) plus RELICARIO_TEST_ITEM_SECRET for the rpassword leg. edit_password_* verifies the item file is rewritten and the edit: bank commit lands. The four history_command_* tests cover masked listing, --show reveal, "no history captured" output, and per-field filtering. The edit_totp_rotates_secret_and_captures_history test (added 2026-04-27 in commit 3f0f5b1 — fixes a stub at the old main.rs:925) drives the full TOTP edit including issuer / label / secret rotation.

  • tests/attachments.rs (106 lines) — attach/attachments/ extract round-trip (verifies the bytes survive the encrypt-decrypt hop); detach removes both the attachment ref and the encrypted blob on disk; detach rejects an unknown aid; attach rejects payloads over per_attachment_max_bytes. The detach test (detach_*) and the cap test were added in 3f0f5b1 / 20350d5 respectively.

  • tests/settings.rs (135 lines) — settings show and settings trash-retention --days 60 round-trip; the conflicting-flags rejection (--days + --forever); the generate_uses_vault_default_length test that verifies (a) default vault length is 20, (b) updating settings generator-defaults --length 32 changes the default, (c) explicit --length 8 overrides the stored default; the multi-shape cmd_status smoke; and the generate_works_outside_vault test that verifies the no-unlock path works in a bare TempDir with no .relicario/.

  • tests/vault_detection.rs (59 lines) — three tests covering audit L8: list refuses without a marker; list from a nested subdirectory finds the parent .relicario/; a v1 .idfoto/ directory is rejected with the .relicario hint in the error message.

The whole test suite uses assert_cmd to spawn the real binary against a real temp directory, so they exercise actual fs / git / KDF code paths. The KDF runs with the production-grade m=64MiB, t=3, p=4 parameters in the test path (main.rs:365), which is why init takes a noticeable beat in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note applies to relicario-core unit tests, not these CLI integration tests.

Gotchas & non-obvious decisions

  • cmd_generate runs without unlock outside a vault, but with unlock inside. This is two ergonomic guarantees in one command. Outside, it's a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any user who installed relicario just for the generator. Inside, it consults settings.generator_defaults so the user gets the policy they configured. The branch is the vault_dir().is_ok() check at main.rs:1440. Tests pin both behaviours.

  • TOTP edit pushes history under the synthetic key core:totp_secret, not core:totp or anything else. This is what relicario history <query> --field totp_secret matches against. The naming convention ("type underscore field") is shared across all five history-tracked fields (see Invariants). If you add a new history-tracked field, pick a matching <type>_<field> form so the user-facing --field filter stays predictable.

  • detach refuses a Document item's primary attachment. (main.rs:1392-1400) Document items model "this item is a file"; the primary blob isn't optional. The error directs the user to purge instead. Non-primary attachments on a Document (e.g., a scanned contract with an addendum) detach normally.

  • Per-type build_*_item / edit_* helpers exist by design after the 3f0f5b1 refactor. Before the refactor, cmd_add and cmd_edit carried 217-line match arms. The split-out functions are easier to read, easier to test individually (the existing integration tests still drive them through the same paths), and easier to grow when a new ItemCore variant lands. Keep this shape — don't fold them back.

  • Why the CLI shells out to git, not libgit2 / gitoxide. Three reasons. (1) Dep tree: pulling in libgit2 doubles compile time and adds a C dependency. (2) Override surface: users can put any ~/.gitconfig they want and it Just Works (subject to the hardening flags). (3) Recovery: when something is wrong with a vault, the user can poke around with git log, git show, git fsck directly; the CLI's git interactions are not opaque.

  • The hardened-git injection set is load-bearing. git_command prepends three -c flags before the user-supplied args (helpers.rs:48-52):

    • core.hooksPath=/dev/null — a malicious or buggy hook in a cloned vault could otherwise run arbitrary code on every commit. Master key is in memory at the time of commit; this matters.
    • commit.gpgsign=false — if the user has global GPG signing on, the GPG agent prompt would block on git commit and hold the master key alive in memory until the user types the passphrase. Disable it for relicario commits.
    • core.editor=truetrue(1) exits 0 with no output. If git decides to drop into $EDITOR (rebase conflict markers, missing -m), this neutralises it without crashing the rebase. We pass -m <msg> ourselves; this flag is the seatbelt. All three were added together in audit H4. A user can still run git themselves with their own config to inspect or repair the vault — the hardening only applies to relicario's invocations.
  • cmd_init uses production-grade KdfParams { m: 65536, t: 3, p: 4 } (main.rs:365), even in tests. RELICARIO_TEST_PASSPHRASE bypasses the prompt but does not lower the KDF cost. This is a trade-off: integration tests pay the full Argon2id cost (~half a second per init on a modern machine), but the same code path runs in production. Don't lower the params here — the core's test-only fast params are for relicario-core unit tests.

  • params.json has a nested kdf object, not a flat one. read_params (session.rs:110-121) deserializes via a private ParamsFile { kdf: KdfParams } struct. The nesting exists so format_version, aead, and salt_path can co-exist in the same file for forward-compat. An earlier version of read_params tried to deserialize the whole file as KdfParams and failed silently — that bug was fixed in commit b263c27.

  • commit_paths is the convention but not always the call site. cmd_purge, cmd_trash_empty, and cmd_device use git_command directly because their add/commit pattern doesn't quite fit commit_paths(vault, msg, &[paths...]). They still use the hardened wrapper, just at one level lower. If you find yourself writing a new command with the same shape, prefer commit_paths; reach for git_command directly only when you need the slightly different control flow these three have.

  • Initial commit at cmd_init does not use commit_paths. Reason: commit_paths takes &UnlockedVault, but cmd_init doesn't construct one — it uses the master key inline before the vault exists. The init commit goes through git_command directly (main.rs:403-412). This is the only production code site outside commit_paths that does so.

  • Lock is a no-op (main.rs:301). The CLI doesn't cache a session — every command re-derives the master key. The command exists only for UX parity with the extension, where lock actually evicts a cached session. Printed message: no cached session to lock.

  • resolve_query accepts an item id or a case-insensitive title substring (main.rs:855-871). Exact id-match wins; otherwise it defers to Manifest::search. Multi-hit substring matches are rejected with an "ambiguous" error listing the matched titles. This is why every cmd_* that takes a query: String (get, edit, history, rm, restore, purge, attach, attachments, extract, detach) works the same way.