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>
29 KiB
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-levelCli/Commandsenum (main.rs:13-275), a flat dispatchermatchinmain()(main.rs:277-303), per-command handlers namedcmd_<verb>, and a layer of per-type item helpers (build_<type>_itemforcmd_add,edit_<type>forcmd_edit). The per-type split is recent: commit3f0f5b1extracted ~217-linematcharms incmd_addandcmd_editinto focused functions, one perItemCorevariant, 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 sharedcommit_pathshelper that is the single chokepoint for git commits during vault mutations. -
src/session.rs(session.rs:1-152) —UnlockedVaultlifecycle. Holds the derived master key inZeroizing<[u8; 32]>for one CLI invocation; the key wipes viaZeroizeon scope exit (session.rs:22-25). Owns theunlock_interactiveflow (vault root walk → salt read → params read → reference image extract → passphrase prompt → KDF) atsession.rs:33-59, the typedload_*/save_*accessors forItem/Manifest/VaultSettings, theread_salt/read_paramshelpers, theRELICARIO_IMAGElookup, andatomic_write(session.rs:144-151) which every disk write to a vault file goes through. Owns the env-var escape hatchesRELICARIO_TEST_PASSPHRASE(session.rs:42) andRELICARIO_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_dirandrelicario_dirwrap it forcwd-rooted callers;git_command(helpers.rs:45-55) is the hardened-gitfactory 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 inZeroizing<[u8; 32]>and drops viaZeroizeon scope exit (session.rs:22-25). No command bypasses this exceptcmd_generateoutside a vault dir andcmd_init(which derives the key inline before there is a vault to unlock). -
Every
gitinvocation in production code goes throughhelpers::git_command. A grep forCommand::new("git")outsidehelpers.rsfinds zero hits insrc/; the only other match is intests/edit_and_history.rs:18, which is test-side verification of the git log and is exempt by design.git_commandinjectscore.hooksPath=/dev/null,commit.gpgsign=false, andcore.editor=truevia-cflags (helpers.rs:48-52). DirectCommand::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>.tmpthen renames over<path>; a partial write never appears as the live file. AllUnlockedVault::save_*helpers route through it. (cmd_initwrites pre-creation files viafs::writeatmain.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 viarelicario_dir.exists()atmain.rs:326.) -
Every commit during a mutating command uses
commit_paths.commit_paths(main.rs:767-775) doesgit 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_purgeandcmd_trash_emptyandcmd_deviceusegit_commanddirectly (notcommit_paths) because they need a slightly different add/commit pattern; they still go through the hardened wrapper. -
cmd_generateis the only command that runs without unlock — and only when invoked outside a vault directory. Inside a vault,cmd_generateunlocks to readsettings.generator_defaults(main.rs:1440-1445); explicit flags override the stored defaults. This is why the smoke-testcargo run -p relicario-cli -- generate --length 32works without any setup. -
Item IDs are minted by core. The CLI never constructs an
ItemIddirectly;Item::new(called inside everybuild_*_item) does it viarelicario-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_listignore it because they read the manifest, not the directory. (Recovery would manually re-addto surface it.) -
Vault root is always discovered, never assumed to be
cwd.helpers::vault_dirwalks up fromcwdlooking for.relicario/, so any command run from a subdirectory of the vault works (verified byvault_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_secretreadsRELICARIO_TEST_ITEM_SECRETbefore falling back torpassword. 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 atmain.rs:308-313.
Key flows
Vault init (cmd_init, main.rs:315-418)
- Refuse if
.relicario/already exists (main.rs:326-328). - Read passphrase twice (or once via
RELICARIO_TEST_PASSPHRASE); confirm they match; runvalidate_passphrase_strength(zxcvbn-backed) and bail with audit-H3 message on weak input (main.rs:331-348). - Generate a 32-byte random
image_secretviaOsRng, embed it into the carrier JPEG viaimgsecret::embed, write the stego output to--output(main.rs:351-360). - Generate a 32-byte salt and pin
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }(production-grade) atmain.rs:363-365. derive_master_key(passphrase, image_secret, salt, params)→Zeroizing<[u8;32]>(main.rs:368).- Create
.relicario/,items/,attachments/dirs; write.relicario/{salt, params.json, devices.json}; encrypt and writemanifest.enc(emptyManifest::new()) andsettings.enc(VaultSettings::default()) (main.rs:370-393). - Write
.gitignorelisting the reference image filename (so the second factor never accidentally ends up in git) (main.rs:396-400). git initthen initial commitinit: new relicario vault (format v2)viagit_command(main.rs:403-412). Note the initial commit does NOT go throughcommit_paths— it precedes the existence of anUnlockedVault, so the path list is hand-spelled.
Vault unlock (UnlockedVault::unlock_interactive, session.rs:33-59)
vault_dir()walks up from cwd to find.relicario/; bails with the "runrelicario initfirst" message on miss (helpers.rs:21-26).read_saltreads.relicario/salt(32 bytes; rejects any other length).read_paramsdeserializes.relicario/params.jsonand extracts the nestedkdfsub-object asKdfParams(session.rs:110-121). The nested shape exists becauseparams.jsonalso storesformat_version,aead, andsalt_pathfor forward-compat probing.get_image_pathhonoursRELICARIO_IMAGE, then a<vault>/reference.jpgconvention, then prompts (session.rs:124-140).- Read the reference image bytes;
imgsecret::extractruns the DCT majority-vote decode to recover the 32-byte image secret (session.rs:38-40). - Read the passphrase via
RELICARIO_TEST_PASSPHRASEorrpassword(session.rs:42-49). derive_master_keyproduces the master key;UnlockedVault { root, master_key }is returned and lives until the command function returns.
Item add (cmd_add, main.rs:419-456)
- Unlock the vault and load the manifest.
- Match on the
AddKindvariant and dispatch to the matchingbuild_<type>_itemhelper (main.rs:423-438). Seven variants → seven builders; onlybuild_document_itemtakes&UnlockedVaultbecause it needsattachment_capsand writes the encrypted blob alongside the item. - The builder returns a fully-populated
Item(with title, group, tags, favorite-flag, primary attachment if any). - Common wrap-up:
vault.save_item(&item),manifest.upsert(&item),vault.save_manifest(&manifest). - Build the path list —
items/<id>.enc,manifest.enc, plus oneattachments/<id>/<aid>.encper attachment — and callcommit_pathswith messageadd: <title> (<id>)(main.rs:444-452).
Item edit (cmd_edit, main.rs:938-977)
- Unlock, load manifest, resolve query → item id, load the item.
- Universally-editable fields (title, group, tags) are prompted via
prompt_keep/prompt_keep_optfirst; blank input keeps the current value (main.rs:952-956). - Borrow
&mut item.field_historyonce into a localhistorybinding (main.rs:958), thenmatchon&mut item.coreand dispatch to the per-typeedit_<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. - 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. item.modified = now_unix(), save, upsert manifest, commitedit: <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) callsItem::soft_delete()(setstrashed_at), saves, upserts manifest, commitstrash:.cmd_restore(main.rs:1178-1193) is the inverse:Item::restore(), same wrap-up, commitrestore:.cmd_purge(main.rs:1220-1237) callspurge_item(main.rs:1197-1218) which removes the item file, the attachment dir, the manifest entry, andgit rm -rf --ignore-unmatchs the paths. Then a singlegit add manifest.enc+ commitpurge: <title> (<id>).cmd_trash_empty(main.rs:1246-1282) is the only multi-item mutating command. It loads settings once, iterates all items past theirtrash_retentionwindow, callspurge_itemfor each, then does a singlegit add manifest.enc+ committrash empty: purged N item(s). The single-unlock-per-batch shape was the fix in commitb5015b3— the earlier version re-prompted for the passphrase per item.
Attach / detach / extract
cmd_attach(main.rs:1283-1339) loadsattachment_capsfrom settings and rejects if the item has hitper_item_max_count.encrypt_attachmentenforcesper_attachment_max_bytes. The encrypted blob lands atattachments/<item_id>/<aid>.enc; theaidis content-addressed by core. Commit message:attach: <file> → <title> (<id>).cmd_detach(main.rs:1376-1424, added in3f0f5b1) removes one attachment from the item, deletes the encrypted blob, rewrites the item. Refuses if the targetaidis aDocumentitem'sprimary_attachment(main.rs:1392-1400) — that would orphan the item; usepurgeinstead. Commit message:detach: <filename> from <title> (<id>).cmd_extract(main.rs:1354-1375) decrypts the blob and writes the plaintext to--outor to<filename>in cwd. Read-only: no commit, no state mutation.cmd_attachments(main.rs:1341-1352) listsaid, size, mime, filename — read-only.
Generate (cmd_generate, main.rs:1426-1489)
Has two distinct modes:
- Outside a vault —
vault_dir()returnsErr;vault_defaultsstaysNone; defaults are hard-coded (length: 20,symbols: SafeOnly,words: 5,separator: " ",Capitalization::Lower). No unlock prompt. - Inside a vault —
vault_dir()succeeds; full unlock; loadsettings.generator_defaults. Explicit flags override the stored defaults field-by-field.--bip39flips mode; absent that flag, the mode is whatever the stored default is. Tests:settings.rs::generate_uses_vault_default_length(length-tracking) andbasic_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_*returnsanyhow::Result<()>. Core errors bubble up through?fromRelicarioError. Per-step context is added viawith_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.,--daysand--forevertogether fail at theSettingsAction::TrashRetentionarm inmain.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 inlist/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:) makesgit log --onelinea literal audit trail. Tests verify this by grepinggit logdirectly (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 insideunlock_interactiveuntil the KDF call (session.rs:40). - Passphrase —
Zeroizing<String>fromrpassword::prompt_passwordor the env var (session.rs:42-49,main.rs:333-342). - Item secrets —
Zeroizing<String>forLogin.password,Card.number,Card.cvv,Card.pin,Key.key_material,SecureNote.body, andZeroizing<Vec<u8>>forTotpCore.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).
- Master key —
-
Test escape hatches. Three env vars exist for integration tests; all are read at exactly one site each:
RELICARIO_TEST_PASSPHRASE—session.rs:42(unlock) andmain.rs:333,338(init).RELICARIO_IMAGE—session.rs:125(image path resolution).RELICARIO_TEST_ITEM_SECRET—main.rs:309(prompt_secretonly). 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 32is 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 freshTempDir, generates a 400×300 JPEG viamake_test_jpeg()(deterministic noise; no binary fixtures), runsrelicario init --image carrier.jpg --output reference.jpgwithRELICARIO_TEST_PASSPHRASEset, and stashes the passphrase + reference image path on the struct.runandrun_with_inputare the two ways to invoke the binary against the test vault: both inheritRELICARIO_IMAGE+RELICARIO_TEST_PASSPHRASE; the latter pipes extra newlines into stdin (used for interactive prompts that aren'trpassword-driven). The note at the top warns Task 23 implementers about the new-item-password rpassword path; the fix landed asRELICARIO_TEST_ITEM_SECRETin commit20350d5. -
tests/basic_flows.rs(136 lines) — covers the init layout (.relicario/{salt,params.json,devices.json},manifest.enc,settings.enc,reference.jpg,.gitignore,.git); theparams.jsonv2 shape;add login+list;getmasking semantics (with and without--show); the rm/restore/purge cycle includinglist --trashed; and the two-modegeneratesmoke (random length + bip39 word count) run outside a vault. -
tests/edit_and_history.rs(191 lines) — driveseditend-to-end by piping stdin lines (blank to keep,yto confirm) plusRELICARIO_TEST_ITEM_SECRETfor the rpassword leg.edit_password_*verifies the item file is rewritten and theedit: bankcommit lands. The fourhistory_command_*tests cover masked listing,--showreveal, "no history captured" output, and per-field filtering. Theedit_totp_rotates_secret_and_captures_historytest (added 2026-04-27 in commit3f0f5b1— fixes a stub at the oldmain.rs:925) drives the full TOTP edit including issuer / label / secret rotation. -
tests/attachments.rs(106 lines) —attach/attachments/extractround-trip (verifies the bytes survive the encrypt-decrypt hop);detachremoves both the attachment ref and the encrypted blob on disk;detachrejects an unknownaid;attachrejects payloads overper_attachment_max_bytes. The detach test (detach_*) and the cap test were added in3f0f5b1/20350d5respectively. -
tests/settings.rs(135 lines) —settings showandsettings trash-retention --days 60round-trip; the conflicting-flags rejection (--days+--forever); thegenerate_uses_vault_default_lengthtest that verifies (a) default vault length is 20, (b) updatingsettings generator-defaults --length 32changes the default, (c) explicit--length 8overrides the stored default; the multi-shapecmd_statussmoke; and thegenerate_works_outside_vaulttest that verifies the no-unlock path works in a bareTempDirwith no.relicario/. -
tests/vault_detection.rs(59 lines) — three tests covering audit L8:listrefuses without a marker;listfrom a nested subdirectory finds the parent.relicario/; a v1.idfoto/directory is rejected with the.relicariohint 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_generateruns 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 installedrelicariojust for the generator. Inside, it consultssettings.generator_defaultsso the user gets the policy they configured. The branch is thevault_dir().is_ok()check atmain.rs:1440. Tests pin both behaviours. -
TOTP edit pushes history under the synthetic key
core:totp_secret, notcore:totpor anything else. This is whatrelicario history <query> --field totp_secretmatches 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--fieldfilter stays predictable. -
detachrefuses 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 topurgeinstead. 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 the3f0f5b1refactor. Before the refactor,cmd_addandcmd_editcarried 217-linematcharms. 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 newItemCorevariant 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~/.gitconfigthey want and it Just Works (subject to the hardening flags). (3) Recovery: when something is wrong with a vault, the user can poke around withgit log,git show,git fsckdirectly; the CLI's git interactions are not opaque. -
The hardened-
gitinjection set is load-bearing.git_commandprepends three-cflags 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 ongit commitand hold the master key alive in memory until the user types the passphrase. Disable it for relicario commits.core.editor=true—true(1)exits 0 with no output. Ifgitdecides 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 rungitthemselves with their own config to inspect or repair the vault — the hardening only applies to relicario's invocations.
-
cmd_inituses production-gradeKdfParams { m: 65536, t: 3, p: 4 }(main.rs:365), even in tests.RELICARIO_TEST_PASSPHRASEbypasses 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 forrelicario-coreunit tests. -
params.jsonhas a nestedkdfobject, not a flat one.read_params(session.rs:110-121) deserializes via a privateParamsFile { kdf: KdfParams }struct. The nesting exists soformat_version,aead, andsalt_pathcan co-exist in the same file for forward-compat. An earlier version ofread_paramstried to deserialize the whole file asKdfParamsand failed silently — that bug was fixed in commitb263c27. -
commit_pathsis the convention but not always the call site.cmd_purge,cmd_trash_empty, andcmd_deviceusegit_commanddirectly because their add/commit pattern doesn't quite fitcommit_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, prefercommit_paths; reach forgit_commanddirectly only when you need the slightly different control flow these three have. -
Initial commit at
cmd_initdoes not usecommit_paths. Reason:commit_pathstakes&UnlockedVault, butcmd_initdoesn't construct one — it uses the master key inline before the vault exists. The init commit goes throughgit_commanddirectly (main.rs:403-412). This is the only production code site outsidecommit_pathsthat does so. -
Lockis 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, wherelockactually evicts a cached session. Printed message:no cached session to lock. -
resolve_queryaccepts an item id or a case-insensitive title substring (main.rs:855-871). Exact id-match wins; otherwise it defers toManifest::search. Multi-hit substring matches are rejected with an "ambiguous" error listing the matched titles. This is why everycmd_*that takes aquery: String(get, edit, history, rm, restore, purge, attach, attachments, extract, detach) works the same way.