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>
31 KiB
Architecture: relicario-core
What this crate is for
relicario-core is the platform-agnostic cryptographic and data-model heart of the
relicario password manager. It is strictly bytes-in / bytes-out: every public
function takes byte slices or owned typed structs and returns byte vectors or typed
structs. The crate performs no filesystem I/O, no network I/O, no git operations,
and no time-of-day reads beyond chrono::Utc::now() for timestamping items
(time.rs:6). This boundary is what lets the same compiled artifact serve the
native CLI (relicario-cli), a wasm32-unknown-unknown build embedded in the
Chrome MV3 / Firefox WebExtension popup (relicario-wasm), and (eventually) ARM
mobile builds — without conditional compilation. Anything that touches a
Path, opens a socket, or shells out belongs in relicario-cli or the
extension layer, never here. The historical rationale is in
docs/superpowers/specs/2026-04-11-relicario-design.md (sections "Crypto
Pipeline" and "Crate Layout").
Module map
lib.rs— Public API surface. Re-exports the symbols that callers actually need (encrypt_item,derive_master_key,Item,ItemCore, etc.). The module list here is the contract; everything else is internal.error.rs—RelicarioError(athiserror-derived enum) plus the crate aliasResult<T> = std::result::Result<T, RelicarioError>. One error type for the whole crate so FFI / WASM bindings and CLI handlers each have a single exhaustivematchto maintain.Decryptis intentionally opaque (no inner detail string) — see "Cross-cutting concerns".crypto.rs— KDF (derive_master_key, Argon2id with NFC-normalized, length-prefixed inputs) and AEAD (encrypt,decrypt, XChaCha20-Poly1305 withVERSION_BYTE = 0x02). Owns the on-disk ciphertext layout. The KDF parameters (KdfParams) are an owned struct that callers persist however they like (CLI puts them in.relicario/params.json); the crate has no opinion about storage.ids.rs—ItemId,FieldId(random 64-bit hex fromOsRng,ids.rs:26-32,ids.rs:38-49) and content-addressedAttachmentId(first 8 bytes ofSHA-256(plaintext),ids.rs:51-57). Three separate newtypes rather thanStringso misuses can't compile.time.rs—now_unix()andMonthYear(the validated 1..=12 / 2000..=2099 card-expiry type). Trivially small; broken out only because every other module needsnow_unix()andMonthYearis used by bothitem.rsanditem_types/card.rs.item_types/mod.rs—ItemTypeenum (snake-case wire tag) andItemCore(internally tagged#[serde(tag = "type")]enum), with one variant per item type. The "extension via match exhaustiveness" pattern is documented atitem_types/mod.rs:1-7: adding an item type is acargo checkwalk through every match arm. Re-exports each per-type core.item_types/login.rs—LoginCore(username, password asZeroizing<String>, optionalUrl, optionalTotpConfig).item_types/secure_note.rs—SecureNoteCore(singleZeroizing<String>body).item_types/identity.rs—IdentityCore(full name, address, phone, email, DOB; all optional, noneZeroizing— they're personal data, not secret material).item_types/card.rs—CardCoreplusCardKind(Credit/Debit/Gift/ Loyalty/Other).number,cvv,pinareZeroizing;holderis plainString.item_types/key.rs—KeyCore: opaqueZeroizing<String>key_materialwith optional label / public key / algorithm. Used for SSH keys, GPG keys, arbitrary blobs.item_types/document.rs—DocumentCore: filename + mime + a singleAttachmentIdpointing at the primary blob. The body lives in the attachment store, not the item.item_types/totp.rs—TotpCore,TotpConfig,TotpAlgorithm(Sha1/Sha256/Sha512),TotpKind(Totp / Hotp{counter} / Steam), and thecompute_totp_code()function. Includes the Steam Mobile Authenticator 5-character alphabet and its conversion (item_types/totp.rs:103-110). The sameTotpConfigis reused as a sub-struct ofLoginCore(so a Login item can carry its own TOTP without spawning a separate item).item.rs— TheItemenvelope. Holds the parallelFieldKind/FieldValueenums (kept parallel so callers can ask the kind without inspecting the value,item.rs:1-6),Field,Section,FieldHistoryEntry, and theItemstruct itself with itsset_field_value/soft_delete/restore/prune_historymutators. Custom-fields and field-history live here, not in the per-type cores.attachment.rs—AttachmentRef(full record carried onItem),AttachmentSummary(compact form carried inManifest),EncryptedAttachment, and theencrypt_attachment/decrypt_attachmenthelpers. The size cap is enforced before any crypto work (attachment.rs:69-74).manifest.rs— The browse-without-decrypt index:Manifest,ManifestEntry,MANIFEST_SCHEMA_VERSION = 2.upsert(&item)rebuilds the entry from the item — there is no path for the manifest to drift from the source-of-truth item file. Includes case-insensitive title/tag search (manifest.rs:59-68) and Login icon-hint derivation (host of the URL,manifest.rs:93-99).settings.rs—VaultSettingsand its sub-types:TrashRetention,HistoryRetention,GeneratorRequest(RandomorBip39),AttachmentCaps, plus theautofill_origin_acksmap for the extension's TOFU prompt.generators.rs— Random-password and BIP-39 passphrase generation, both driven byGeneratorRequestfromsettings.rs. zxcvbn-backedrate_passphraseand thevalidate_passphrase_strengthgate that rejects any score < 3.vault.rs— Typed wrappers aroundcrypto::{encrypt, decrypt}:encrypt_item/decrypt_item,encrypt_manifest/decrypt_manifest,encrypt_settings/decrypt_settings. Each doesserde_json::to_vec → encrypt(or the inverse). The plaintextVec<u8>is wrapped inZeroizingbetween serde and the cipher (vault.rs:18-19,vault.rs:24-26).imgsecret.rs— Self-contained DCT-based steganography for the second auth factor. Owns its ownYChannel,EmbedRegion, 8×8 DCT/IDCT, Quantization Index Modulation, and crop-recovery extractor. No other module imports it; it is consumed only via the public re-export fromlib.rs.
Invariants & contracts
- No filesystem, no network, no git, no spawn. Verified by inspecting
imports; the only I/O-shaped types in use are in-memory
Cursor<&[u8]>for image decoding (imgsecret.rs:243). - No
unsafe. Confirmed bygrepoversrc/. The crate compiles to WASM unmodified for that reason. - No
async. All operations are pure compute on byte slices. Async lives inrelicario-cli(process spawning) and in the extension's service worker (message channels), not here. VERSION_BYTE = 0x02(crypto.rs:59). Every blob produced byencrypt()starts with this byte;decrypt()rejects any other value withRelicarioError::UnsupportedFormatVersion { found, expected }(crypto.rs:127-132). v1 blobs (the pre-rewrite format) are explicitly tested for rejection (tests/format_v2.rs:28-42).- AEAD blob layout is fixed at
version(1) || nonce(24) || ciphertext+tag(≥16)(crypto.rs:18-32). Minimum valid blob length is 41 bytes (crypto.rs:118-124). - Nonces are always fresh from
OsRng(crypto.rs:87-89). There is no caller-supplied nonce path. With 192 bits of randomness, collision risk is negligible across the lifetime of any vault. MANIFEST_SCHEMA_VERSION = 2(manifest.rs:12). v1 manifests (which predate typed items) are not handled here and are rejected at the JSON-parse step.- KDF input is length-prefixed.
derive_master_keybuilds the password buffer asu64_be(len(passphrase)) || passphrase || u64_be(32) || image_secret(crypto.rs:229-236). This eliminates the ("abc",0x44…) vs ("abcD",…) collision, and is exercised incrypto.rs:352-368andtests/format_v2.rs:44-54. - Passphrases are NFC-normalized before hashing. Bytes that aren't valid
UTF-8 pass through unchanged (
crypto.rs:223-227). This keeps "café" (precomposed) and "café" (combining acute) from producing different keys (crypto.rs:370-385). - Master key only ever lives in
Zeroizing<[u8; 32]>. Returned that way byderive_master_key(crypto.rs:212) and accepted that way byencrypt_item/encrypt_attachment/ friends. No public function invault.rsorattachment.rsaccepts a raw[u8; 32]. - Plaintext is wrapped in
Zeroizingbetween serde and the cipher. Seevault.rs:18-19,vault.rs:24-26,vault.rs:31-32,vault.rs:37-38,vault.rs:44-45,vault.rs:50-51. The serde JSON intermediate buffer is the most exposed point, so it is wiped on drop. AttachmentIdis content-addressed to the first 8 bytes (= 16 hex chars) ofSHA-256(plaintext)(ids.rs:51-57). Identical plaintexts deduplicate in git automatically — proven intests/attachments.rs:28-35. The 64-bit prefix is used (rather than the full digest) to keep filenames short; the collision space is still adequate for the expected vault size.ItemIdandFieldIdare 16 hex chars = 64 bits ofOsRngentropy (ids.rs:25-32,ids.rs:38-49). The audit (M8) bumped them from the original 8-char / 32-bit format.- Field kind/value discriminants must agree.
Field::newderiveskindfromvalue(item.rs:85-94);Field::validate(called after deserialize) rejects any mismatch (item.rs:97-107).set_field_valuefurther refuses to change a field's kind (item.rs:184-189). - Field-history capture is restricted to three kinds:
Password,Concealed,Totp(item.rs:68-71). Any other kind's update silently skips history. The TOTP secret is base32-encoded for the history entry (item.rs:245-249) so a user reading their history sees a recognizable string. - History captures the previous value, not the new one (
item.rs:190-197):set_field_valueserializesfield.valuebefore assigning the new value. hidden_by_defaultis set automatically when the field's kind isPasswordorConcealed(item.rs:92). The extension and CLI both honor this hint when rendering.- Attachment cap is checked before encryption (
attachment.rs:69-74). An oversize blob fails withRelicarioError::AttachmentTooLarge { size, max }without ever callingencrypt. The CLI/extension are expected to read the cap fromVaultSettings::attachment_caps. Item::soft_deletedoes not erase data. It setstrashed_atand bumpsmodified(item.rs:205-208). Purging is the caller's responsibility, driven byTrashRetention::should_purge(settings.rs:38-44).prune_historyis idempotent and explicit. Items keep all history until the caller invokes it with aHistoryRetentionpolicy (item.rs:219-237). Last-N drops oldest first; Days drops anything older thannow - days·86400.item_type()is the single source of truth for the type tag stored onItem.Item::newderivesr#typefrom the suppliedItemCore(item.rs:159-164). Manual construction can violate this — the JSON round-trip does not re-validate beyond serde's tag matching.- Reserved serde key: no
*Coremay have a JSON-serialized field named"type"— that name is reserved for serde's discriminator onItemCore(item_types/mod.rs:38-40). Use"kind"instead (seeCardKind,TotpKind). MAX_DIMENSION = 10_000for imgsecret (imgsecret.rs:71). Enforced via a header-only peek (imgsecret.rs:127-176) at the entry of bothembedandextractso an attacker-supplied 32000×32000 JPEG is rejected without decoding pixels (audit M3).MIN_DIMENSION = 100plus a "must hold ≥5 redundant copies" floor (imgsecret.rs:66,imgsecret.rs:78,imgsecret.rs:682-689). Smaller carriers are rejected withImageTooSmall.- Strength gate is
score >= 3(generators.rs:124-130). Vault-creation callers must invokevalidate_passphrase_strengththemselves; the crate does not internally call it insidederive_master_key(since that path is also used to derive the key for unlock, not just create). SymbolCharset::Custommust be ASCII-only (generators.rs:46-52). Non-ASCII custom charsets are rejected withRelicarioError::Format.
Key flows
Vault unlock — key derivation
- Caller obtains
passphrase: &[u8](UTF-8) andimage_secret: &[u8; 32](typically fromimgsecret::extractover the user's reference JPEG). - Caller loads
salt: [u8; 32]andKdfParamsfrom out-of-band storage (CLI:.relicario/saltand.relicario/params.json). derive_master_key(passphrase, &image_secret, &salt, ¶ms)—crypto.rs:207-244:- NFC-normalize the passphrase if it parses as UTF-8 (
crypto.rs:223-227). - Build the length-prefixed password buffer in a
Zeroizing<Vec<u8>>(crypto.rs:229-236). - Run
Argon2idwithAlgorithm::Argon2id,Version::V0x13, output length 32 (crypto.rs:213-221,crypto.rs:238-241).
- NFC-normalize the passphrase if it parses as UTF-8 (
- Returns
Zeroizing<[u8; 32]>— automatically wiped on drop.
A wrong passphrase or wrong image produces a different derived key. The crate
cannot tell them apart at this stage; the caller learns "wrong factor" only
when subsequent decrypt_* returns RelicarioError::Decrypt.
Item write
- Caller mutates an
Item(e.g.item.set_field_value(&fid, new_value)—item.rs:181-203).set_field_valuecaptures previous value intofield_historyif the kind is history-tracked, then bumpsmodified. - Caller calls
encrypt_item(&item, &master_key)—vault.rs:16-20:serde_json::to_vec(item)→ wrap inZeroizing→crypto::encrypt. - Caller calls
manifest.upsert(&item)(manifest.rs:45-48) to refresh the browse-index entry; thenencrypt_manifest(&manifest, &master_key)(vault.rs:29-33). - The two ciphertext blobs are returned to the caller, who writes them to disk (or commits them, or sends them over a sync channel).
Item read (browse-without-decrypt path)
- Caller calls
decrypt_manifest(&manifest_blob, &master_key)(vault.rs:35-40). One AEAD decryption gets the entire searchable index. Manifest::search(query)does a case-insensitive substring match over title and tags (manifest.rs:59-68).manifest.items.values()gives everyManifestEntrywithtitle,tags,favorite,group,icon_hint,modified,trashed_at, andattachment_summaries— enough to render a list UI without touching any item file.- When the user picks an entry, the caller reads
entries/<id>.encand callsdecrypt_item(&blob, &master_key)(vault.rs:22-27) to get the fullItemincluding secret fields andfield_history.
Attachment encryption
- Caller has
plaintext: &[u8], themaster_key, and the activeVaultSettings::attachment_caps.per_attachment_max_bytes. encrypt_attachment(plaintext, &master_key, max_bytes)—attachment.rs:64-78:- If
plaintext.len() > max_bytes, returnAttachmentTooLargeimmediately before any crypto. AttachmentId::from_plaintext(plaintext)(SHA-256,ids.rs:51-57).crypto::encrypt(master_key, plaintext).
- If
- Returns
EncryptedAttachment { id, bytes }. The caller persistsbytesatattachments/<id>.encand adds anAttachmentRef { id, filename, mime_type, size, created }(attachment.rs:11-20) to the owningItem. OnManifest::upsert, anAttachmentSummary(nocreatedfield) is derived automatically (manifest.rs:87).
Field-history capture
- Triggered exclusively by
Item::set_field_value(item.rs:181-203). Direct mutation offield.valuebypasses history — the type system does not prevent this. - The check
field.value.is_history_tracked()runs on the existing value (item.rs:190), so adding the first password value to a previously-empty field does not create a history entry; updating an already-set password does. - The previous value is serialized via
serialize_history_value(item.rs:241-253):Password(p)andConcealed(c)clone the inner string into a freshZeroizing<String>.Totp(cfg)base32-encodes the raw secret bytes (item.rs:245-249,item.rs:256-275).- Any other kind would error (
item.rs:250), but is unreachable becauseis_history_trackedalready gated the call.
- Pruning is not automatic. Callers (CLI commit hook, extension save handler)
call
item.prune_history(&settings.field_history_retention, now_unix())when they want to enforce the policy.
imgsecret embed
- Caller passes a JPEG byte slice and a 32-byte secret to
imgsecret::embed(carrier_jpeg, &secret)(imgsecret.rs:666-726). enforce_dimension_capwalks JPEG markers (imgsecret.rs:127-161) to read the SOF dimensions; rejects > 10_000 × 10_000 before any pixel decode.extract_y_channeldecodes viaimage::ImageReaderand converts each pixel to BT.601 luminance (imgsecret.rs:242-265).central_regionpicks the inner 70% of the image as the embed region; the 15% margin per side is the "crumple zone" for crops (imgsecret.rs:268-293).compute_embed_positions/select_embed_blockslay outnum_copies × BLOCKS_PER_COPY8×8 blocks evenly across the region, withnum_copies=min(50, total_blocks / 22)(imgsecret.rs:530-575).- For each block: 2D DCT (
dct2_8x8,imgsecret.rs:393-412) → embed 12 bits into the 12 mid-frequency coefficients listed inEMBED_POSITIONS(zig-zag positions 6–17,imgsecret.rs:105-118) via QIM withQUANT_STEP = 50.0(imgsecret.rs:462-467) → 2D inverse DCT → write back into Y. reconstruct_jpeg(imgsecret.rs:590-640) re-derives Cb/Cr per pixel from the original RGB (so chrominance is preserved), combines with the modified Y, and re-encodes at JPEG quality 92.
imgsecret extract (with crop recovery)
extract(jpeg_bytes)enforces the dimension cap, then delegates toextract_with_crop_recovery(imgsecret.rs:738-741,imgsecret.rs:849-899).- Try 1 — assume uncropped:
try_extract_with_layout(&y, w, h, 0, 0). This is the hot path; for a freshly embedded image it always succeeds. - Try 2 — width-only crop, block-aligned: iterate
orig_wfrom current width up to1.20 × current_win 8-px steps, withdx = 0(assume right-edge crop). - Try 3 — height-only crop, block-aligned: same strategy on the vertical axis.
- Try 4 — width crops at non-block-aligned 1-px steps, skipping any already covered in Try 2.
try_extract_with_layout(imgsecret.rs:754-834) tallies QIM votes for each of the 256 bit positions across allnum_copiescopies. Each bit must reach ≥60% confidence (imgsecret.rs:824); below that, the whole extraction fails withExtractionFailed(no partial result is ever returned).- The 60% threshold is per-bit, not aggregate — a single unconfident bit aborts the whole try. This makes false-positive extractions from never-embedded images vanishingly unlikely.
Cross-cutting concerns
- Error model.
RelicarioError(error.rs:15-89) is a singlethiserror-derived enum.Decryptis the deliberately-opaque "wrong key or tampered ciphertext" variant (audit M4 —error.rs:28-30,tests/integration.rs:99-111): the message is just"decryption failed"with no inner string, and it does not distinguish wrong-passphrase from wrong-image-secret from corrupted ciphertext.Formatis the "input bytes don't make sense" variant (e.g. blob too short, schema mismatch).UnsupportedFormatVersionis the structured "wrong version byte" variant — separate fromFormatbecause callers want to react to it differently (offer migration, etc.). - Where secrets live. Every secret type wraps
Zeroizing<...>:- The derived master key:
Zeroizing<[u8; 32]>(crypto.rs:212). - Field values:
FieldValue::Password(Zeroizing<String>)andFieldValue::Concealed(Zeroizing<String>)(item.rs:39-40). FieldHistoryEntry::value:Zeroizing<String>(item.rs:127).- Per-type cores:
LoginCore::password,CardCore::{number,cvv,pin},KeyCore::key_material,SecureNoteCore::body,TotpConfig::secret(aZeroizing<Vec<u8>>of the raw HMAC key). - Decrypted attachment plaintext:
Zeroizing<Vec<u8>>(attachment.rs:88-92). - Argon2id input buffer (
crypto.rs:232) and JSON serialization buffers invault.rsare wrapped inZeroizingto wipe the intermediate plaintext.
- The derived master key:
- Format versioning. Three independent version channels exist, each
gating something different:
crypto::VERSION_BYTE = 0x02(crypto.rs:59) — gates the AEAD blob layout. Bumped if the nonce length, header layout, or cipher changes. A v1 blob is rejected with a typedUnsupportedFormatVersion { found: 0x01, expected: 0x02 }.manifest::MANIFEST_SCHEMA_VERSION = 2(manifest.rs:12) — gates the JSON-level shape of the manifest. v1 manifests had a different layout and would fail to parse against the currentManifeststruct.- The
.relbakimport/export format defined indocs/superpowers/specs/2026-04-27-relicario-import-export-design.mdwill introduce a third version channel for backups; that surface lives outside this crate.
- KDF parameter handling.
KdfParams(crypto.rs:156-168) is just a serializable struct. The crate has no opinion about where it is stored, how it is rotated, or who increments it.Defaultgives the production values (m=65536,t=3,p=4—crypto.rs:175-183) calibrated for ~0.5–1 s on a modern desktop. Tests universally use the fast triplet(m=256, t=1, p=1)defined as afn fast_params()near the top of every test file. - NFC normalization is the only Unicode op. All passphrase canonicalization
happens in one place (
crypto.rs:223-227). Item titles, field labels, tags, etc. are stored verbatim — only the passphrase fed to the KDF is normalized. - No per-entry subkeys. Every encrypted blob (item, manifest, settings,
attachment) is encrypted with the same master key. The design rationale
is in
docs/superpowers/specs/2026-04-11-relicario-design.mdlines 66: per-entry subkey derivation would add complexity for no real-world benefit given the expected family-vault size. - CSPRNG is
OsRngeverywhere.ItemId::new,FieldId::new,derive_master_key(no-op — the salt is caller-supplied),crypto::encrypt(nonce),generators::random_password,generators::bip39_passphrase. A singlerand::thread_rng()call exists inside animgsecrettest (imgsecret.rs:1033) to generate a random test secret; production code isOsRngonly. ed25519-dalekis a dependency placeholder. Listed inCargo.toml:17but unused insrc/. It exists for the future device-key surface (RelicarioError::DeviceKeyis the reserved variant,error.rs:84-88); device-key signing currently happens inrelicario-cliinstead.
Test architecture
All tests/ files use the fast Argon2id triplet m=256, t=1, p=1 so the
suite runs in seconds, not minutes. Test JPEGs are synthesized at runtime via
make_test_jpeg(width, height) (imgsecret.rs:908-924) — a deterministic RGB
pattern at quality 92 — so no binary fixtures live in git.
tests/integration.rs— End-to-end vault workflows: encrypt+decrypt a Login and a SecureNote throughManifest/VaultSettings, two-factor independence (different passphrase or different image_secret yields different keys), field-history surviving an encrypt/decrypt round-trip, and the wrong-key-→-Decryptopaqueness contract.tests/attachments.rs— Round-trip a 5 KB blob, prove identical plaintexts produce identicalAttachmentIds (despite different ciphertext bytes due to fresh nonces), and exercise the cap boundary at exactly the max byte and one over.tests/field_history.rs— Sequentialset_field_valuecalls accumulate history in oldest→newest order;prune_history(LastN(3))keeps the most recent 3; field-history survivesencrypt_item→decrypt_item.tests/format_v2.rs—VERSION_BYTE == 0x02, fresh ciphertext starts with0x02, a v1-shaped blob ([0x01][24 nonce][16 tag]) is rejected with the typedUnsupportedFormatVersion, and the length-prefix construction prevents("abc", 0x44…)/("abcD", …)collisions.tests/generators.rs— Aggregates 80 × 128 = 10,240 chars fromgenerate_passwordto assert per-character-class proportions are within ±5 pp of the expected uniform distribution; verifies that 5-word BIP-39 passes the strength gate while common weak passwords ("password", "12345678", "letmein", "qwertyui", "hunter2") all fail; asserts uniqueness across 1000 default-config calls. The opening doc comment (tests/generators.rs:1-13) explains why the original "10,000-char single call" plan switched to aggregation:generate_passwordenforceslength ≤ 128.
In-module #[cfg(test)] mod tests blocks cover unit-level invariants (kind/
value mismatches, snake-case serde tags, base32 round-trips, MonthYear
constructor bounds, the Steam alphabet ambiguity audit). The imgsecret
test block additionally proves DCT round-tripping, QIM noise tolerance below
Q/4 = 12.5, embed→Q85-recompress→extract round-trip, embed→10%-crop→extract
round-trip, and the oversized-image-header rejection path.
Gotchas & non-obvious decisions
QUANT_STEP = 50.0is intentionally double the academic value of 25 (imgsecret.rs:62). Higher quantization steps make the watermark more robust to JPEG recompression at Q85 and below — at the cost of more visible artifacts in the carrier. The reference image is a personal photo, not a publication, so the trade-off favors robustness.- The embed region is the central 70% (15% margin per side, "crumple
zone") —
imgsecret.rs:212-218,imgsecret.rs:276-293. Anything in the outer 15% is sacrificed so that mild edge crops (e.g. social-media platform trims) leave the embedded data intact. Tested up to 10% crop inimgsecret.rs:1108-1137. - Per-bit majority voting with a 60% confidence floor.
try_extract_with_layouttallies votes from every redundant copy and fails the entire extraction if any single bit position is below 60% agreement (imgsecret.rs:824). This is more conservative than a global threshold and is what makes false positives from never-embedded images essentially zero — seeextract_from_non_embedded_image_fails(imgsecret.rs:1041-1045). - Number of redundant copies is capped at 50 (
imgsecret.rs:536,imgsecret.rs:692-693). Beyond that, per-block visual artifacts compound faster than the error-correction benefit grows. peek_jpeg_dimensionswalks JPEG markers manually instead of using theimagecrate.imgsecret.rs:127-161. A fullImageReader::decodeof an attacker-supplied 30 000 × 30 000 JPEG would allocate ~3.6 GB of pixel buffer in the WASM service worker before failing — the manual walk reads only the SOF segment and bails in O(marker-count) (audit M3).bip39always generates 128 bits of entropy (12 mnemonic words) and truncates toword_count(generators.rs:82-89). This is becausebip39 v2rejects entropy below 128 bits, but we want to support 3–12 word passphrases. Truncation preserves the per-word independence — the words the user sees still come from a uniformly-sampled-then-truncated 12-word draw.- Steam TOTP output is exactly 5 characters from a 26-glyph alphabet,
regardless of the
digitsfield onTotpConfig(item_types/totp.rs:103-110, asserted initem_types/totp.rs:240-253). The alphabet (23456789BCDFGHJKMNPQRTVWXY) excludes0/O,1/I/L,S(so5is unambiguous),A,E,U,Z— all glyphs Valve considered ambiguous in the Steam Mobile Authenticator. Verified atitem_types/totp.rs:274-283. ItemCoreis internally-tagged with#[serde(tag = "type")]— the outer JSON object gets a"type"key. This means no*Corestruct may have a field literally namedtype. The convention chosen for type-discriminant fields inside a core iskind— seeCardKind,TotpKind(item_types/mod.rs:38-40).- The TOTP base32 in field-history strips padding.
base32_encode(item.rs:256-275) is RFC-4648 with no=padding — appropriate because the value is for human display in history, not for re-decoding. AttachmentId::from_plaintextuses only the first 8 bytes (= 16 hex chars) of the SHA-256 digest (ids.rs:51-57). 64 bits of collision resistance is sufficient for a personal-vault attachment count; it keeps filenames short. If a future use case demands collision resistance against motivated adversaries (e.g. dedup across untrusted vaults), this width is the lever.Field::newderiveskindfromvalue, but the public struct still stores both (item.rs:73-94). The duplication exists so callers can match onkindwithout inspecting (and potentially decrypting / cloning)value.validate()is the safety net that runs after deserialization.set_field_valuerefuses to change a field's kind (item.rs:184-189). The intent is that fields are conceptually fixed-shape after creation; changing aTextto aPasswordshould be done by deleting the old field and creating a new one (so history doesn't get confused).hidden_by_defaultis notZeroize. It's purely a UI hint — the rendering layer (CLI output, popup card) decides whether to mask the value on initial display. Secrecy at rest is enforced by theZeroizingwrappers on the value itself, not this flag.Manifest::upsertrebuilds the entry from scratch every call (manifest.rs:45-48,manifest.rs:75-89). There is no "patch the existing entry" path. This means the manifest can never carry a staleicon_hintorattachment_summaries— they are derived freshly from the sourceItemeach time.- The strength gate is not called inside
derive_master_key. It must be invoked separately by the caller during vault creation only — not during unlock, where calling it would let an attacker probe whether a wrong passphrase happens to be "strong enough" before the Argon2id work even starts. Seegenerators.rs:124-130. now_unix()ischrono::Utc::now().timestamp()and is the single time source in this crate (time.rs:6-8). Tests that need determinism pass an explicitnow: i64toprune_history(item.rs:219) and similar — they do not stubnow_unix.