72 Commits

Author SHA1 Message Date
adlee-was-taken
76f34bfcf5 chore: remove stray vault files from Plan 1B + add plan doc
A Task 6 implementer subagent ran `relicario init` inside the worktree
root during manual testing and committed the resulting vault skeleton
(.relicario/, manifest.enc, settings.enc) plus overwrote .gitignore.
None of these should be in the source repo.

Restores the original .gitignore (adds reference.jpg and ref.jpg to it)
and checks in the Plan 1B design doc that describes the work just merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:50:37 -04:00
adlee-was-taken
e0c511e320 Merge Plan 1B: typed-item CLI + WASM bridge
34 commits from plan-1a-rust-core-complete landing:
- Rename reconciliation (Task 1)
- Core imgsecret MAX_DIMENSION cap (Task 2, audit M3)
- CLI rewrite against typed-item core API (Tasks 3-17)
- WASM opaque SessionHandle bridge (Tasks 18-21)
- CLI integration test harness + tests (Tasks 22-24)
- CLAUDE.md typed-item layout refresh (Task 25)

Audit fixes: H4 H5 H6 H7 M3 M6 M7 M11 L8.
Tests: 151 passing (core + CLI + WASM native), WASM target builds clean.
Tag: plan-1b-cli-wasm-complete
2026-04-20 18:48:56 -04:00
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
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
45 changed files with 9909 additions and 1799 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/
reference.jpg
ref.jpg

View File

@@ -10,8 +10,10 @@ relicario is a git-backed, self-hostable password manager with a Rust core. Two-
cargo build # build everything
cargo test # run all tests (unit + integration)
cargo test -p relicario-core # core library tests only
cargo run -- --help # CLI help
cargo run -- generate -l 32 # quick smoke test
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
@@ -22,15 +24,26 @@ crates/
│ ├── src/
│ │ ├── lib.rs # Re-exports public API
│ │ ├── error.rs # RelicarioError 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
└── relicario-cli/ # CLI binary
└── src/
── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
│ │ ├── 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
@@ -49,14 +62,14 @@ 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.

1091
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,11 +12,22 @@ path = "src/main.rs"
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "5"
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

@@ -12,7 +12,19 @@ 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
@@ -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::{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;
@@ -121,12 +123,12 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
));
}
let version = data[0];
if version != VERSION_BYTE {
return Err(RelicarioError::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]);
@@ -207,7 +209,7 @@ pub fn derive_master_key(
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,
@@ -218,17 +220,24 @@ pub fn derive_master_key(
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)
.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,7 +292,7 @@ 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]
@@ -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,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

@@ -10,7 +10,7 @@ use thiserror::Error;
/// 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 RelicarioError {
@@ -25,12 +25,8 @@ pub enum RelicarioError {
#[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 RelicarioError {
#[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
@@ -84,3 +90,44 @@ pub enum RelicarioError {
/// Crate-wide result alias, reducing boilerplate in function signatures.
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

@@ -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.
@@ -601,6 +664,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
/// or does not have enough blocks for reliable embedding.
/// - [`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 {
@@ -672,6 +736,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
/// - [`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)
}
@@ -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

@@ -10,17 +10,22 @@
//!
//! ## Modules
//!
//! - [`error`] -- The unified error type ([`RelicarioError`]) 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.
//! - [`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
//!
@@ -36,12 +41,39 @@ pub mod error;
pub use error::{RelicarioError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
pub mod entry;
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
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_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
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

@@ -1,150 +1,90 @@
//! Typed encryption/decryption wrappers for vault entries and manifests.
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
//!
//! 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).
//! 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 crate::crypto;
use crate::entry::{Entry, Manifest};
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::Result;
use crate::item::Item;
use crate::manifest::Manifest;
use crate::settings::VaultSettings;
/// 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::RelicarioError::Json`] if JSON serialization fails (should not happen
/// with well-formed Entry structs).
/// - [`crate::RelicarioError::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)
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())
}
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
///
/// # Errors
///
/// - [`crate::RelicarioError::Decrypt`] if the master key is wrong or the data is
/// tampered.
/// - [`crate::RelicarioError::Format`] if the ciphertext blob has an invalid header.
/// - [`crate::RelicarioError::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)
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)
}
/// 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>> {
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json)
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
/// 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)?;
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::entry::ManifestEntry;
use crate::item_types::{ItemCore, SecureNoteCore};
fn test_key_a() -> [u8; 32] {
[0x42u8; 32]
}
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 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 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 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()));
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 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());
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

@@ -1,153 +1,111 @@
use relicario_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::RngCore;
//! End-to-end integration tests for the typed-item core.
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
}
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,
}
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);
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();
// 2. Generate random image_secret and embed
let mut image_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret);
let stego = relicario_core::imgsecret::embed(&carrier, &image_secret).unwrap();
// 3. Extract and verify
let extracted = relicario_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 settings = VaultSettings::default();
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
// 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();
assert_eq!(manifest_dec.version, 1);
assert!(manifest_dec.entries.contains_key(&entry_id));
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
// 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() {
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
// Same passphrase, different image_secret → different keys.
let salt = [0u8; 32];
let img_a = [0x01u8; 32];
let img_b = [0x02u8; 32];
let passphrase_a = b"passphrase-alpha";
let passphrase_b = b"passphrase-bravo";
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);
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;
// 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);
}
// 1. (passphrase_A, image_A)
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, &params).unwrap();
#[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();
// 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");
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();
// 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");
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

@@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
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

@@ -1,364 +1,282 @@
//! WASM bindings for the relicario password manager.
//! WASM bindings for relicario.
//!
//! This crate wraps [`relicario_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).
//! 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::crypto::{self, KdfParams};
use relicario_core::entry::Entry;
use relicario_core::vault;
use relicario_core::imgsecret;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
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.
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
#[wasm_bindgen]
pub fn derive_master_key(
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_secret: &[u8],
image_bytes: &[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())
) -> 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))
}
/// 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()))
pub fn lock(handle: &SessionHandle) -> bool {
session::remove(handle.0)
}
/// 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()))
}
// Subsequent wasm_bindgen fns added in Tasks 19-21.
/// 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"))?;
relicario_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: relicario_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))
}
use serde_wasm_bindgen::Serializer;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
Item, Manifest, VaultSettings,
};
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))
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
else { Err(JsError::new("invalid or locked session handle")) }
}
/// 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()
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()))
}
/// 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()
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 tests {
mod session_tests {
use super::*;
use zeroize::Zeroizing;
#[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");
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 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");
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 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");
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());
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,58 @@
/// Type declarations for the relicario 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.
export class SessionHandle {
readonly value: number;
free(): void;
}
declare module 'relicario-wasm' {
export default function init(input?: string | URL): Promise<void>;
export function derive_master_key(
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_secret: Uint8Array,
image_bytes: 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;
}
): 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>;