97 Commits

Author SHA1 Message Date
adlee-was-taken
33d2a4a311 feat(ext/setup): wizard Style C progress track, glyph mode icons, recovery QR banner
- Replace dot-based progress indicator with colored horizontal segment track
  (completed=green, active=gold, pending=border) via renderProgressTrack()
- Add SETUP_STEP_NAMES constant for track segment titles
- Update Step 0 mode cards with glyph icons (◈ create, ⌥ attach)
- Add recovery QR banner in Step 5 (new-vault only, verifiedHandle present)
  with Generate now / Skip buttons wired in attachStep5()
- Add CSS for .setup-progress-track, .setup-progress-segment variants,
  and .recovery-qr-banner to styles.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:17:05 -04:00
adlee-was-taken
f17944a404 fix(core,wasm): correct QR version comment, expect msg, zeroize image_secret in closure 2026-05-03 21:09:02 -04:00
adlee-was-taken
4851857070 feat(ext/settings): settings-security.ts three-state recovery QR + devices component
- Add settings-security.ts with renderSecuritySection / teardownSecuritySection
- Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG)
- Device list with inline revoke; passphrase collected via prompt()
- QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored
- Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES
- Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr
- Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:06:43 -04:00
adlee-was-taken
a6071b4c0c feat(cli): recovery-qr generate / unwrap subcommands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:01:29 -04:00
adlee-was-taken
ada00895d4 feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings 2026-05-03 20:57:55 -04:00
adlee-was-taken
42b746f9af feat(wasm): session stores image_secret for recovery QR generation 2026-05-03 20:56:39 -04:00
adlee-was-taken
762a008171 test(core): recovery_qr roundtrip + error cases 2026-05-03 20:53:59 -04:00
adlee-was-taken
f93bce7388 chore(core): re-export recovery_qr module 2026-05-03 20:51:36 -04:00
adlee-was-taken
8eabaf5f31 feat(core): recovery_qr generate + unwrap + SVG functions 2026-05-03 20:51:33 -04:00
adlee-was-taken
04142dc116 feat(core): add derive_master_key_raw + RecoveryQr error variant 2026-05-03 20:51:29 -04:00
adlee-was-taken
8739f1f67b chore(core): add qrcode dependency for recovery QR 2026-05-03 20:48:38 -04:00
adlee-was-taken
7d6fd76e86 feat: v0.5.1 multi-agent coordination plans (PM + DEV-A/B/C)
- coordination/v0.5.1-pm-prompt.md — PM coordinates 3 streams, enforces
  interface contracts (A-B settings signature, B-C security component),
  owns merge order and pre-tag checklist
- coordination/v0.5.1-dev-a-prompt.md — Stream A: fullscreen 3-column
  layout, sidebar category nav, detail drawer, bottom sheet, popup type-
  picker polish, per-type glyph icons, empty states, toast system (13 tasks)
- coordination/v0.5.1-dev-b-prompt.md — Stream B: settings left-nav
  redesign (Autofill, Display, Security, Generator, Retention, Backup,
  Import sections), security component stub (10 tasks)
- coordination/v0.5.1-dev-c-prompt.md — Stream C: recovery_qr.rs core,
  WASM session expansion, CLI subcommand, settings-security.ts three-state
  component, setup wizard Style C redesign + QR banner (12 tasks)
- Archive v0.5.0 coordination files to coordination/archive/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:26:19 -04:00
adlee-was-taken
4dc034d846 docs(spec): v0.5.x UX polish, settings redesign, and recovery QR design
Three-stream spec for the next release train:
- Stream A: fullscreen 3-col layout, popup type-picker polish, glyphs, toasts, empty states
- Stream B: settings UX redesign with left-nav sections (Device/Vault split)
- Stream C: recovery QR crypto (Rust/WASM), setup wizard redesign (Style C), security settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:32:43 -04:00
adlee-was-taken
3021ef9d9f feat(ext/vault): sidebar logo before "Relicario" wordmark
Renders the 16-optimized SVG (icons/relicario-logo-16.svg) inline
before the brand text in .vault-sidebar__header. Sized to 20×20 px
with flex-shrink: 0 so it survives narrow-pane wraps. The header
already had display: flex + gap: 8px, so the layout absorbed the new
element without further changes. Popup surface is untouched (this
override is scoped to .vault-sidebar__header .brand-logo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 18:53:09 -04:00
adlee-was-taken
b2749826b1 docs: CHANGELOG entry for v0.5.0
Three release trains roll into one tag — v0.3.0 backup/restore +
LastPass import, v0.4.0 device authentication, and the v0.5.0
polish + harden bundle.

Renames the existing "Unreleased" heading to v0.5.0 — 2026-05-02
and prepends the polish + harden additions:

- Security: S1 pre-receive hook fix (HIGH-severity authentication
  bypass), S2 tar-restore path-traversal hardening, S3 RELICARIO_*
  env-var audit + cfg-gate.
- Fixed: B1 strength-meter regenerate desync, B2/P4 raw error-code
  leakage in the fullscreen tab.
- Added: P1 password coloring (four reveal surfaces + settings UI),
  P2 setup → fullscreen vault tab handoff. Existing v0.3.0/v0.4.0
  Added entries (sync, register-from-popup, generator-defaults, edit
  TOTP, history, detach, status, backup/restore, vault-tab panel,
  LastPass import + popup deep link, status export age) preserved
  verbatim.
- Changed: P3 form-layout envelope, doc-audit refresh across
  overview / CLAUDE / SECURITY / ARCHITECTURE / foundational spec.
- Internal: C1 stale-branch prune, clippy cleanup, Cargo.lock
  regenerated, CLI/extension refactors preserved from prior trains.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:48:22 -04:00
adlee-was-taken
a332a9e80d Merge feature/v0.5.0-plan-a-security-cleanup: Plan A security + cleanup
v0.5.0 Plan A — Security Fixes + Repo Cleanup. 7 commits, ~800 net
insertions across the Rust workspace. Four items delivered:

- S1 (HIGH-severity authentication bypass fix): rewrite verify_commit
  in relicario-server. The previous implementation accepted any
  GOODSIG/Good signature line on stderr, ignoring whether the signing
  key was registered or revoked. The new implementation:
  * builds a temp gpg.ssh.allowedSignersFile from devices.json at the
    commit (no global git-config mutation)
  * parses the SHA-256 fingerprint from `git verify-commit --raw`
    stderr via regex
  * checks revocation FIRST (revoked entries may have been removed
    from devices.json), with the historical-commit case
    (committer_ts < revoked_at) explicitly allowed
  * uses committer date (GIT_COMMITTER_DATE / `git show -s
    --format=%ct`), not author date or wall clock
  * tightened the bootstrap guard to require BOTH devices and revoked
    to be empty (closes an empty-devices.json privilege-escalation
    route present in the original code)
  * 4 acceptance integration tests build real on-disk repos with
    SSH-signed commits and verify each scenario

- S2 (tar archive path-traversal hardening): replace
  tar::Archive::unpack with safe_unpack_git_archive. Located in
  relicario-core (per-spec, so integration tests can reach it without
  the bytes-in/bytes-out invariant breaking). Validates each entry's
  type (rejects symlinks/hardlinks), path components (rejects '..',
  RootDir, Windows drive Prefix), and declared size (rejects
  individual or cumulative > 100×compressed-or-1-GiB whichever is
  lower). The CLI's restore path adds a paranoid OS-level
  starts_with(.git/) check on the joined destination as
  defense-in-depth even after textual validation. 5 acceptance tests
  cover path traversal, symlinks, oversized headers (header claim of
  2 GiB tested without allocating disk).

- S3 (RELICARIO_* env-var audit): docs/SECURITY.md gains a
  "Configuration env vars" section enumerating each variable, its
  purpose, and trust assumption. Active-in-all-builds variables
  (RELICARIO_IMAGE, RELICARIO_GITEA_*) are documented; debug-only
  variables (RELICARIO_NO_GROUPS_CACHE, RELICARIO_TEST_*) are gated
  behind cfg(debug_assertions) so the env-var lookup is removed from
  --release binaries.

- C1 (stale feature branch prune): 5 merged feature branches and
  3 worktrees pruned interactively per dev report.

- Bonus: 4d02a50 fixes pre-existing clippy warnings across
  crates/relicario-{core,cli} (deref operators, Option::is_none_or
  vs map_or(true, ...), iter_mut().enumerate() patterns,
  div_ceil()) so the workspace builds clean under `-D warnings`.

Merge resolution: docs/SECURITY.md had a conflict where main's F11/F12
(Device Authentication paragraph naming relicario-server + simplified
"Device registration is optional" line) collided with Plan A's S3
section. Resolved by keeping both — F11/F12's wording for the
Device Authentication section, then Plan A's "Configuration env vars"
section appended below.

Cargo.lock regenerated. The previous committed lock was stale since
commit 8855078 (--totp-qr); cargo test on both devs' worktrees
produced identical regenerated locks. Plan A genuinely added regex +
tempfile to relicario-server (both already transitively present from
relicario-cli), so no new top-level deps; the Cargo.lock churn is
catch-up of crate-version bumps that have happened since the last
commit-of-record.

Tests: 248 cargo tests pass; extension tests unchanged (336/8 with 8
pre-existing device-auth scaffolding failures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:54:12 -04:00
adlee-was-taken
d45dd10917 Merge feature/v0.5.0-plan-b-extension-ux: Plan B extension UX
v0.5.0 Plan B — Extension UX Polish + Bug Fixes. 15 commits, 22 files,
+853/-33 lines, all in extension/. Five features delivered:

- P4: ERROR_COPY centralized map; popup humanizeError now a thin shell
  over lookupErrorCopy; fullscreen tab gets friendly title/body/CTA blocks
  (closes B2). Generated test enumerates every grep'd error code so the
  registry can't drift.
- B1: applyGeneratedPassword dispatches a synthetic input event after
  the regenerate handler sets the password value, so the strength-meter
  listener re-rates the new value.
- P1: end-to-end password coloring — pure colorizePassword utility,
  chrome.storage.sync round-trip via applyColorScheme, CSS rules with
  custom properties, four reveal surfaces (popup item-detail, vault
  item-detail, field-history, generator preview), boot wiring + storage
  listener, Display section in settings with color pickers + swatch +
  reset.
- P3: .form-lower wrapper constrains lower form sections (notes,
  custom-fields, attachments, actions) to the same max-width: 960px
  envelope as .form-grid above, gated on surface === 'fullscreen' so
  the popup is unaffected.
- P2: finishSetup() opens the fullscreen vault tab and best-effort
  closes the setup tab after successful device registration. Both
  create-new and attach-existing flows funnel through it.

Implementation notes:
- vault.ts uses event delegation on the stable #vault-app root for
  .error-cta clicks (better than the plan's per-render handler attach;
  survives re-renders without leaking listeners).
- fields.ts gained a kind: 'password' | 'concealed' option on
  ConcealedRowOpts so wireFieldHandlers can apply colorizePassword
  selectively at the shared rendering layer.
- New WASM stub at src/__stubs__/relicario_wasm.stub.ts + vitest config
  alias lets unit tests import setup.ts without exploding on the
  runtime-only WASM module.

Tests: +28 (336/8 vs main's 308/8); 8 pre-existing device-auth
scaffolding failures unchanged. Builds clean: cargo wasm + Chrome
bundle + Firefox bundle.

Manual acceptance items (P3 viewport sweep at 1920/1440/1024/768,
P2 setup-flow smoke) deferred to user's pre-tag smoke walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:42:22 -04:00
adlee-was-taken
4d02a50cc8 chore(core): fix pre-existing clippy warnings (-D warnings gate)
Resolves pre-existing lint issues in imgsecret.rs, time.rs, totp.rs,
and crypto.rs that blocked the cargo clippy --workspace -D warnings
gate. No logic changes: loop-index → iterator, manual div_ceil →
.div_ceil(), manual range contains → .contains(), auto-deref cleanup.

Also fixes pre-existing warnings in relicario-cli (main.rs, session.rs,
device.rs, gitea.rs, helpers.rs, test helpers): dead_code suppression,
too_many_arguments, literal_with_empty_format_string, manual_char_cmp,
map_or → is_none_or, and repeat().take() → vec! in test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:32:45 -04:00
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
5d9a7ee8d3 docs(multi-agent): expand kickoff section with full spec→plans→launch workflow 2026-05-02 18:51:52 -04:00
adlee-was-taken
006e67c361 fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)
The groups-cache opt-out is a developer debugging knob, not a
user-facing config. Gating the env-var lookup behind cfg!(debug_assertions)
makes release builds ignore the variable; the optimiser removes the
lookup entirely, so the variable name doesn't appear in release binary
strings output.

Doc-comments updated to reflect the new behaviour.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:51:15 -04:00
adlee-was-taken
95d1ff833c docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)
Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from debug-only ones (cfg(debug_assertions))
to make the attack surface explicit for security reviewers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:50:44 -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
4fc1357368 docs: add multi-agent development paradigm README 2026-05-02 17:19:25 -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
df58b0dda1 chore(relay): add relay MCP server to project Claude config 2026-05-02 17:18:47 -04:00
adlee-was-taken
ed9fcbe6ba feat(relay): start.sh launcher with --manual/--tmux/--kitty modes 2026-05-02 17:18:31 -04:00
adlee-was-taken
0172a06698 feat(relay): MCP SSE server with post_message/read_messages/list_pending 2026-05-02 17:17:41 -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
6d5a2570d4 feat(relay): in-memory queue with consume-once semantics 2026-05-02 17:16:21 -04:00
adlee-was-taken
6a1c6d5875 fix(core,cli): harden backup-restore tar unpack against path traversal (audit S2)
cmd_backup_restore previously called tar::Archive::unpack with default
settings, allowing malicious .relbak archives to escape the target
directory via .. entries, absolute paths, or symlinks. No size cap
meant tar bombs could exhaust disk space.

Replaced with relicario_core::safe_unpack_git_archive which:
- Rejects .. (ParentDir), absolute (RootDir), and drive-prefix
  (Prefix) components with "path traversal blocked" error.
- Rejects symlinks and hardlinks outright.
- Checks declared header size before reading body; rejects entries or
  cumulative totals exceeding the caller's cap.
- Returns (relative-path, bytes) pairs; the CLI re-checks
  dest.starts_with(git_dir) after OS-level path resolution.
- CLI cap: min(100 × compressed size, 1 GiB).

Acceptance: 5 unit tests in relicario-core (traversal, absolute path,
symlink, size bomb, happy path); existing CLI backup roundtrip tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:16:11 -04:00
adlee-was-taken
6d8f699fcb chore(relay): scaffold tools/relay with MCP SDK dep 2026-05-02 17:15:46 -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
c0921b134d docs(plan): relay server implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:44: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
0443f6a3b4 docs(spec): add top-level README section to relay server design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:38:30 -04:00
adlee-was-taken
5e8e617a4d docs(spec): relay server design for multi-agent message bus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:37:17 -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
efac53d527 fix(server): real signature verification in pre-receive hook (audit S1)
verify_commit previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as
no-ops: unregistered keys could push, revoked keys kept working.

The fix:
- Build a temp gpg.ssh.allowedSignersFile from devices.json at the
  commit, passed via GIT_CONFIG_COUNT/KEY/VALUE env (no global git
  config mutation).
- Run git verify-commit --raw and parse SHA256 fingerprint from stderr
  regardless of exit code (SSH git outputs the "Good" line even for
  keys not in allowed-signers, with "No principal matched" + exit 1).
- Check revoked.json FIRST: reject if committer_ts >= revoked_at;
  accept historical commits (committer_ts < revoked_at).
- Reject if fingerprint is not in active devices.json.
- Bootstrap: accept only when BOTH devices.json AND revoked.json are
  empty/absent (not just devices.json alone).

Acceptance: 4 integration tests covering the matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:34:37 -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
af8626fb5f docs(audit): mark all 8 proposed findings fixed (PM follow-up)
Updates each Status: line from "Proposed; needs user decision" to
the actual fix-commit SHA. The audit doc now records the full state:
6 trivial findings fixed in the initial 900ccf1 pass; 8 deeper
findings fixed across ca059e7, 8fd9a05, 1342228, 76d092d, 9c97f9f
during v0.5.0 PM kickoff.

Pre-tag checklist: doc-audit follow-ups item is now done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:28:08 -04:00
adlee-was-taken
9c97f9f939 docs(spec): banner foundational design spec as historical (audit F13)
The 2026-04-11 design spec lists secure notes, secure documents, TOTP,
Firefox extension, LastPass import, and device authentication as
"Post-V1 Ideas" — most of which shipped over the following weeks.
Per the doc/architecture/overview.md convention, specs are frozen
decision artifacts and shouldn't be retro-edited; instead, add a
one-line status banner pointing readers at CHANGELOG.md and the
overview doc for current state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:39 -04:00
adlee-was-taken
76d092d4f6 docs(architecture): note settings.enc + typed items in vault-creation flow (audit F10)
The Vault Creation Flow ASCII showed only manifest.enc as init's
encrypted artifact; cmd_init has been writing settings.enc in parallel
since the VaultSettings rollout. Update the encrypt step to show both
artifacts side-by-side with independent nonces.

Below the ASCII, add a short pointer noting that the per-item lifecycle
(typed-item envelope, attachment encryption, field-history) lives in
crates/relicario-core/ARCHITECTURE.md and reuses the same master_key +
XChaCha20-Poly1305 primitives. The doc-audit framing is "this top-level
doc could just point at the per-crate docs" — taking that trim path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26: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
1342228a51 docs(security): name relicario-server in device-auth section (audit F11/F12)
- F12: Device Authentication section now names the relicario-server crate
  and its two subcommands (generate-hook, verify-commit), and notes that
  signed commits without the server-side hook provide authorship only —
  any pusher can still land an unsigned commit.
- F11: drop the "optional before v0.4.0" version line (v0.4.0 was never
  tagged; v0.5.0 is the first release with the hook) and replace with a
  one-liner: registration is optional but recommended for shared vaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:25:21 -04:00
adlee-was-taken
d539050aec chore(server): add assert_cmd/predicates/tempfile dev-deps
Needed for the upcoming verify-commit acceptance suite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:24 -04:00
adlee-was-taken
8fd9a05875 docs(claude): refresh project tree, IDs line, and roadmap (audit F2/F3/F4)
- F2: add relicario-server crate to the project-structure tree
- F3: replace stale "Next: WASM + Chrome MV3 (Plan 2)" roadmap line with
  the v0.5.0/Phase-3/1C-γ/LastPass picture
- F4: ItemIds and FieldIds are 16-char hex (64 bits) per audit M8;
  AttachmentIds are first 32 hex of SHA-256 (128 bits) per audit I2/B4

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:23:17 -04:00
adlee-was-taken
8a72b5e192 feat(core): add device::fingerprint helper for SSH SHA256 fingerprints
Wraps ssh-key's PublicKey::fingerprint(HashAlg::Sha256). Output format
matches ssh-keygen -lf and git verify-commit --raw stderr
(SHA256:<43-char base64>). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:10 -04:00
adlee-was-taken
ca059e7507 docs(overview): add relicario-server crate to four-codebase framing
Doc-audit Finding 1. The repo has had four Rust crates since early May
when the pre-receive hook crate landed, but docs/architecture/overview.md
still framed itself around three. Update:

- "The three codebases" → "The four codebases" (intro + heading)
- ASCII diagram fans core out to cli + server + wasm, with wasm feeding
  the extension
- Table gains a relicario-server row noting it lives on the git server
  and only sees public key material
- Build matrix adds `cargo build -p relicario-server --release`
- "Where to look next" points at server src + the device-auth design spec

Server has no user-facing surface, so the CLI/extension parity rule is
clarified to exclude it (it is server-side enforcement of an invariant
the clients already agreed to).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:20:45 -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
114 changed files with 23226 additions and 540 deletions

View File

@@ -1,4 +1,10 @@
{ {
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": { "enabledPlugins": {
"superpowers@claude-plugins-official": true "superpowers@claude-plugins-official": true
} }

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ extension/dist-firefox/
extension/wasm/ extension/wasm/
reference.jpg reference.jpg
ref.jpg ref.jpg
tools/relay/node_modules/

View File

@@ -1,9 +1,76 @@
# Changelog # Changelog
## Unreleased ## v0.5.0 — 2026-05-02
Three release trains roll into one tag — backup/restore + LastPass
import (originally v0.3.0), device authentication (originally v0.4.0),
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
two confirmed bugs).
### Security
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
Earlier `relicario-server` builds accepted any commit with a
`Good signature` line on stderr regardless of which key signed it —
device-auth was a no-op. The hook now builds an `allowed_signers`
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
global git-config mutation), parses the SSH SHA-256 fingerprint out
of `git verify-commit --raw` stderr, and rejects unregistered keys or
revoked keys whose committer-date is at or after the revocation
timestamp. Bootstrap mode is preserved only when **both**
`devices.json` AND `revoked.json` are empty (closes an
empty-devices.json privilege-escalation route).
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
A new `relicario_core::safe_unpack_git_archive` validates each
entry's path components (rejects `..`, absolute paths, Windows
drive prefixes), rejects symlinks/hardlinks, and caps total
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
check after path-joining as defense-in-depth.
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
developer escape hatch, not a user knob) is now
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
the env-var lookup is removed from the binary by the optimiser.
### Fixed
- **Strength meter no longer goes stale after the regenerate button (B1).**
Programmatic `input.value = newPassword` doesn't fire `input`
events; the regenerate handler now dispatches a synthetic
`InputEvent('input', { bubbles: true })` so the meter listener
re-rates the new value.
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
used to render verbatim in the fullscreen vault tab and (in some
cases) the popup. New `extension/src/shared/error-copy.ts` central
registry maps every service-worker error code to friendly
title/body/CTA copy; the popup and fullscreen tab consume the
same map. The fullscreen lock screen's `vault_locked` block now
reads `Vault locked / Unlock your vault to continue. / [Unlock
vault]`. A generated test enumerates the live error codes via
grep so the registry can't drift.
### Added ### Added
- **Sidebar logo in the fullscreen vault tab.** The
`vault-sidebar__header` now renders the 16-optimized SVG logo
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
so it survives narrow-pane wraps). Popup unaffected.
- **Password coloring (P1).** Revealed passwords in the popup
item-detail, fullscreen item view, field-history viewer, and
generator preview render digits and symbols in distinct colors.
Defaults: blue digits, red symbols. Users can override via the
new Display section in settings (color pickers + live preview
swatch + reset). Defaults round-trip via
`chrome.storage.sync.password_display_scheme`; cross-device when
Chrome sync is enabled.
- **Setup wizard hands off to the fullscreen vault tab on completion
(P2).** Both create-new and attach-existing flows now open
`vault.html` in a new tab and best-effort close the setup tab
after device registration succeeds — replaces the prior
setup-tab-stays-open terminal screen.
- **Sync now button** in the extension settings view — surfaces the - **Sync now button** in the extension settings view — surfaces the
previously hidden `{ type: 'sync' }` SW message to users with success / previously hidden `{ type: 'sync' }` SW message to users with success /
error feedback. error feedback.
@@ -59,6 +126,30 @@
file `cmd_backup_export` writes on success). Reads "never" for file `cmd_backup_export` writes on success). Reads "never" for
fresh vaults, "4 days ago" otherwise. fresh vaults, "4 days ago" otherwise.
### Changed
- **Form layout in the fullscreen vault tab is now visually consistent
(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
the `.form-grid` cards above. Removes the visual rhythm break at the
2-col → full-width transition. The popup surface is unchanged.
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
`docs/architecture/overview.md` now describes four codebases (the
`relicario-server` pre-receive hook crate is no longer invisible);
`CLAUDE.md` project tree and roadmap reflect current state;
`docs/SECURITY.md` names the server crate and its `verify-commit` /
`generate-hook` subcommands and notes the without-the-hook-it's-
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
parallel artifact in the vault-creation flow; the foundational
design spec gains a "historical" status banner pointing readers at
the current docs.
- `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).
### Known limitations ### Known limitations
- **Mid-restore failure leaves the target remote in a half-written - **Mid-restore failure leaves the target remote in a half-written
@@ -74,6 +165,13 @@
### Internal ### Internal
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
builds clean under `-D warnings`.
- `Cargo.lock` regenerated and committed; was stale since the
`--totp-qr` commit.
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant - Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
now has its own `build_*_item` / `edit_*` helper. Pure mechanical now has its own `build_*_item` / `edit_*` helper. Pure mechanical
extraction; behavior unchanged. The dispatcher matches and delegates. extraction; behavior unchanged. The dispatcher matches and delegates.
@@ -83,14 +181,6 @@
`setup.ts` since it walks live wizard state. Setup.ts went from `setup.ts` since it walks live wizard state. Setup.ts went from
1205 → 1137 lines. 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 ## v0.2.0 — 2026-04-27
### Fixed ### Fixed

View File

@@ -48,9 +48,11 @@ crates/
│ ├── src/helpers.rs # vault_dir, git_command, iso8601 │ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing) │ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection │ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
── relicario-wasm/ # WASM bindings for the extension ── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface ├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]> └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
└── src/main.rs # verify-commit + generate-hook subcommands
``` ```
## Key design decisions ## Key design decisions
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever. - 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. - Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Item IDs are random 8-char hex strings. - 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 over the plaintext (128 bits).
- Git history is preserved as an audit log — no squashing. - Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency. - The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
## Roadmap ## Roadmap
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM). Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.

941
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,4 +4,5 @@ members = [
"crates/relicario-core", "crates/relicario-core",
"crates/relicario-cli", "crates/relicario-cli",
"crates/relicario-wasm", "crates/relicario-wasm",
"crates/relicario-server",
] ]

View File

@@ -33,7 +33,9 @@ To unlock the vault, you provide your passphrase and point the client at the ref
A git repository containing: A git repository containing:
- `manifest.enc` — opaque binary blob - `manifest.enc` — opaque binary blob
- `entries/*.enc` — more opaque binary blobs - `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/salt` — a random 32-byte value (not secret)
- `.relicario/params.json` — Argon2id parameters (not secret) - `.relicario/params.json` — Argon2id parameters (not secret)
- `.relicario/devices.json` — authorized device public keys - `.relicario/devices.json` — authorized device public keys
@@ -114,12 +116,23 @@ relicario/
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network) │ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD │ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs │ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
│ │ ├── entry.rs # Entry, Manifest data model (serde) │ │ ├── item.rs # Item, Field, Manifest data model (serde)
│ │ ── vault.rs # Encrypt/decrypt entries and manifests │ │ ── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
└── relicario-cli/ # CLI binary: filesystem, git, terminal I/O │ ├── 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/ └── docs/
├── ARCHITECTURE.md # System overview + flow diagrams
├── SECURITY.md # Manifest integrity model + threat notes
├── architecture/ # Cross-codebase + per-codebase architecture docs
└── superpowers/ └── superpowers/
└── specs/ # Design specification with full threat model └── specs/ # Design specifications with full threat model
``` ```
`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). `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).
@@ -144,17 +157,22 @@ Every write generates a fresh random nonce. The version byte allows future forma
``` ```
my-vault.git/ my-vault.git/
├── manifest.enc # Encrypted entry index (names, URLs, timestamps) ├── manifest.enc # Encrypted item index (names, URLs, timestamps)
├── entries/ ├── settings.enc # Encrypted vault settings (retention, caps, generator defaults)
│ ├── a1b2c3d4.enc # One encrypted entry per file ├── items/
── e5f6a7b8.enc ── a1b2c3d4e5f6a7b8.enc # One encrypted item per file
│ └── …
├── attachments/
│ └── <item-id>/
│ └── <aid>.enc # Content-addressed encrypted attachment blob
└── .relicario/ └── .relicario/
├── salt # 32-byte random salt (not secret) ├── salt # 32-byte random salt (not secret)
├── params.json # KDF parameters ├── 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 ## Device management
@@ -183,13 +201,17 @@ The binary is at `target/release/relicario`.
## Roadmap ## Roadmap
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging) - [x] WASM build + Chrome MV3 browser extension (inline crypto, no native messaging)
- [ ] Secure notes (free-form encrypted text entries) - [x] Firefox WebExtension build
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB) - [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) - [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
- [ ] Android/iOS clients (Rust core compiles to ARM) - [ ] Android/iOS clients (Rust core compiles to ARM)
- [ ] Import from LastPass/Bitwarden/1Password - [ ] Safari extension
- [ ] Firefox/Safari extensions
## License ## License

View File

@@ -17,7 +17,6 @@ arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "5" dirs = "5"
hex = "0.4" hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -28,10 +27,11 @@ tar = { version = "0.4", default-features = false }
clap_complete = "4" clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7" rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2"
predicates = "3" predicates = "3"
tempfile = "3" tempfile = "3"
qrcode = "0.14"
serde_json = "1" serde_json = "1"

View File

@@ -0,0 +1,169 @@
//! 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.
#[allow(dead_code)]
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.
#[allow(dead_code)]
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.
#[allow(dead_code)]
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,117 @@
//! 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,
#[allow(dead_code)]
pub title: String,
#[allow(dead_code)]
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.
#[allow(dead_code)]
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

@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
} }
/// Path to the `.relicario/` configuration directory within the vault. /// Path to the `.relicario/` configuration directory within the vault.
#[allow(dead_code)]
pub fn relicario_dir() -> Result<PathBuf> { pub fn relicario_dir() -> Result<PathBuf> {
Ok(vault_dir()?.join(".relicario")) Ok(vault_dir()?.join(".relicario"))
} }
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
/// ///
/// **Plaintext leak:** group names land on disk in cleartext alongside the /// **Plaintext leak:** group names land on disk in cleartext alongside the
/// vault directory. This is intentional — the file feeds shell completion, /// vault directory. This is intentional — the file feeds shell completion,
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1` /// which cannot prompt for a passphrase. In debug builds, set
/// to suppress the write. /// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf { pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(".relicario").join("groups.cache") vault_dir.join(".relicario").join("groups.cache")
} }
/// Write the sorted set of group names to `<vault_dir>/.relicario/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. /// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (developer debugging tool). In release builds the env
/// var is ignored.
pub fn write_groups_cache( pub fn write_groups_cache(
vault_dir: &Path, vault_dir: &Path,
groups: &std::collections::BTreeSet<String>, groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
return Ok(()); return Ok(());
} }
let path = groups_cache_path(vault_dir); let path = groups_cache_path(vault_dir);
@@ -115,6 +118,21 @@ pub fn write_groups_cache(
std::fs::write(path, body) 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 /// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
/// QR decodes to an `otpauth://...` URI with a `secret` query param. /// QR decodes to an `otpauth://...` URI with a `secret` query param.
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> { pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
@@ -179,6 +197,29 @@ mod tests {
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z"); 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] #[test]
fn humanize_age_buckets() { fn humanize_age_buckets() {
assert_eq!(humanize_age(0), "just now"); assert_eq!(humanize_age(0), "just now");

View File

@@ -2,6 +2,8 @@
//! //!
//! See module docs for the unlock flow and vault layout. //! See module docs for the unlock flow and vault layout.
mod device;
mod gitea;
mod helpers; mod helpers;
mod session; mod session;
@@ -158,15 +160,9 @@ enum Commands {
/// Sync with the git remote (pull --rebase + push). /// Sync with the git remote (pull --rebase + push).
Sync, Sync,
/// Print a summary of the vault: items, attachments, devices, last commit. /// Print a summary of the vault: items, attachments, last commit.
Status, Status,
/// Device management.
Device {
#[command(subcommand)]
action: DeviceAction,
},
/// Lock the vault (no-op in CLI; present for UX parity with the extension). /// Lock the vault (no-op in CLI; present for UX parity with the extension).
Lock, Lock,
@@ -174,7 +170,7 @@ enum Commands {
/// ///
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read /// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
/// which the CLI refreshes on every manifest read. Set /// which the CLI refreshes on every manifest read. In debug builds, set
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
/// will fall back to no value enumeration). /// will fall back to no value enumeration).
/// ///
@@ -194,6 +190,18 @@ enum Commands {
/// Passphrase to score, or `-` to read from stdin. /// Passphrase to score, or `-` to read from stdin.
passphrase: String, passphrase: String,
}, },
/// Manage registered devices (signing keys + deploy keys).
Device {
#[command(subcommand)]
action: DeviceAction,
},
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
RecoveryQr {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -312,13 +320,6 @@ enum SettingsAction {
}, },
} }
#[derive(Subcommand)]
enum DeviceAction {
Add { #[arg(long)] name: String },
List,
Revoke { name: String },
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum BackupAction { enum BackupAction {
/// Pack the local vault into a single encrypted `.relbak` file. /// Pack the local vault into a single encrypted `.relbak` file.
@@ -360,6 +361,62 @@ enum ImportAction {
}, },
} }
#[derive(Subcommand)]
enum DeviceAction {
/// Register this machine as a new device.
///
/// Generates two ed25519 keypairs: one for signing commits, one for push
/// access (deploy key). The deploy public key is registered via the Gitea
/// API. Both private keys are stored locally in
/// `~/.config/relicario/devices/<name>/`. The vault's `.relicario/devices.json`
/// is updated and committed.
///
/// Required environment variables (or flags):
/// RELICARIO_GITEA_URL — e.g. https://git.example.com
/// RELICARIO_GITEA_TOKEN — personal access token with repo write access
/// RELICARIO_GITEA_OWNER — repository owner
/// RELICARIO_GITEA_REPO — repository name
Add {
/// Human-readable name for this device (e.g. "laptop-2026").
#[arg(long)]
name: String,
/// Gitea API base URL (overrides RELICARIO_GITEA_URL).
#[arg(long)]
gitea_url: Option<String>,
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
#[arg(long)]
gitea_token: Option<String>,
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
#[arg(long)]
owner: Option<String>,
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
#[arg(long)]
repo: Option<String>,
/// Skip Gitea API registration (useful when the remote is not Gitea).
#[arg(long)]
no_gitea: bool,
},
/// Revoke a registered device.
///
/// Removes the device from `devices.json`, adds it to `revoked.json`,
/// deletes the deploy key from Gitea, and commits the change.
Revoke {
/// Name of the device to revoke.
#[arg(long)]
name: String,
},
/// List registered devices.
List,
}
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
/// Generate a recovery QR code and display it as ASCII art in the terminal.
Generate,
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
Unwrap,
}
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
@@ -385,7 +442,6 @@ fn main() -> Result<()> {
Commands::Settings { action } => cmd_settings(action), Commands::Settings { action } => cmd_settings(action),
Commands::Sync => cmd_sync(), Commands::Sync => cmd_sync(),
Commands::Status => cmd_status(), Commands::Status => cmd_status(),
Commands::Device { action } => cmd_device(action),
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
Commands::Completions { shell } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
@@ -393,6 +449,8 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
Commands::Rate { passphrase } => cmd_rate(passphrase), Commands::Rate { passphrase } => cmd_rate(passphrase),
Commands::Device { action } => cmd_device(action),
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
} }
} }
@@ -414,11 +472,41 @@ fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::
let _ = helpers::write_groups_cache(vault_dir, &set); let _ = helpers::write_groups_cache(vault_dir, &set);
} }
/// Check for test passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
pub(crate) fn test_passphrase_override() -> Option<String> {
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
pub(crate) fn test_passphrase_override() -> Option<String> {
None
}
/// Check for test item secret override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_item_secret_override() -> Option<String> {
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
}
#[cfg(not(debug_assertions))]
fn test_item_secret_override() -> Option<String> {
None
}
/// Check for test backup passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_backup_passphrase_override() -> Option<String> {
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
fn test_backup_passphrase_override() -> Option<String> {
None
}
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` /// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
/// for integration-test use (rpassword reads /dev/tty by default, which is /// for integration-test use (rpassword reads /dev/tty by default, which is
/// unavailable in assert_cmd-spawned children). /// unavailable in assert_cmd-spawned children).
fn prompt_secret(label: &str) -> Result<String> { fn prompt_secret(label: &str) -> Result<String> {
if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") { if let Some(s) = test_item_secret_override() {
return Ok(s); return Ok(s);
} }
rpassword::prompt_password(label).map_err(Into::into) rpassword::prompt_password(label).map_err(Into::into)
@@ -442,12 +530,12 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
// Passphrase with strength gate (audit H3). // Passphrase with strength gate (audit H3).
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
// TTY prompt so integration tests can run without a real TTY. // TTY prompt so integration tests can run without a real TTY.
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { let passphrase = if let Some(p) = test_passphrase_override() {
Zeroizing::new(p) Zeroizing::new(p)
} else { } else {
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
}; };
let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some() { let confirm = if test_passphrase_override().is_some() {
passphrase.clone() passphrase.clone()
} else { } else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
@@ -467,7 +555,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
}; };
let carrier = fs::read(&image) let carrier = fs::read(&image)
.with_context(|| format!("failed to read carrier image {}", image.display()))?; .with_context(|| format!("failed to read carrier image {}", image.display()))?;
let stego = imgsecret::embed(&carrier, &*image_secret)?; let stego = imgsecret::embed(&carrier, &image_secret)?;
fs::write(&output, &stego) fs::write(&output, &stego)
.with_context(|| format!("failed to write reference image {}", output.display()))?; .with_context(|| format!("failed to write reference image {}", output.display()))?;
@@ -477,7 +565,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
// Derive master key, then persist an empty Manifest + default VaultSettings. // Derive master key, then persist an empty Manifest + default VaultSettings.
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, &params)?; let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)?;
fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(root.join("items"))?; fs::create_dir_all(root.join("items"))?;
@@ -497,8 +585,6 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
salt_path: ".relicario/salt".into(), salt_path: ".relicario/salt".into(),
})?, })?,
)?; )?;
fs::write(relicario_dir.join("devices.json"), b"[]")?;
let manifest = Manifest::new(); let manifest = Manifest::new();
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
let settings = VaultSettings::default(); let settings = VaultSettings::default();
@@ -515,7 +601,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
let status = crate::helpers::git_command(&root, &["init"]).status()?; let status = crate::helpers::git_command(&root, &["init"]).status()?;
if !status.success() { anyhow::bail!("git init failed"); } if !status.success() { anyhow::bail!("git init failed"); }
let _ = crate::helpers::git_command(&root, &[ let _ = crate::helpers::git_command(&root, &[
"add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", "add", ".gitignore", ".relicario/params.json",
".relicario/salt", "manifest.enc", "settings.enc", ".relicario/salt", "manifest.enc", "settings.enc",
]).status()?; ]).status()?;
let status = crate::helpers::git_command(&root, &[ let status = crate::helpers::git_command(&root, &[
@@ -562,7 +648,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str())); paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
} }
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?; commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
eprintln!("Added: {} (id={})", item.title, item.id.as_str()); eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(()) Ok(())
@@ -574,6 +660,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
// (for attachment-cap settings + writing the encrypted blob alongside // (for attachment-cap settings + writing the encrypted blob alongside
// the item). // the item).
#[allow(clippy::too_many_arguments)]
fn build_login_item( fn build_login_item(
title: Option<String>, title: Option<String>,
username: Option<String>, username: Option<String>,
@@ -789,6 +876,7 @@ fn build_document_item(
Ok(item) Ok(item)
} }
#[allow(clippy::too_many_arguments)]
fn build_totp_item( fn build_totp_item(
title: Option<String>, title: Option<String>,
issuer: Option<String>, issuer: Option<String>,
@@ -853,7 +941,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> { fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY. // Accepts MM/YYYY or MM-YYYY or MM/YY.
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') let (m_str, y_str) = s.split_once(['/', '-'])
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
let month: u8 = m_str.parse().context("invalid month")?; let month: u8 = m_str.parse().context("invalid month")?;
let year: u16 = if y_str.len() == 2 { let year: u16 = if y_str.len() == 2 {
@@ -927,12 +1015,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
if let Some(u) = &l.url { println!("URL: {u}"); } if let Some(u) = &l.url { println!("URL: {u}"); }
if let Some(t) = &l.totp { if let Some(t) = &l.totp {
if show { if show {
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret)); println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
} else { } else {
println!("TOTP: **** (use --show to reveal)"); println!("TOTP: **** (use --show to reveal)");
} }
} }
if let Some(p) = &l.password { Some(p.clone()) } else { None } l.password.clone()
} }
ItemCore::SecureNote(n) => { ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); } if show { println!("Body:\n{}", n.body.as_str()); }
@@ -1054,8 +1142,8 @@ fn cmd_list(
Some(t) => e.r#type == t, Some(t) => e.r#type == t,
None => true, None => true,
}) })
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) .filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
.collect(); .collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
@@ -1064,7 +1152,7 @@ fn cmd_list(
return Ok(()); return Ok(());
} }
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
for e in entries { for e in entries {
let fav = if e.favorite { " *" } else { "" }; let fav = if e.favorite { " *" } else { "" };
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
@@ -1107,7 +1195,7 @@ fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
manifest.upsert(&item); manifest.upsert(&item);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
refresh_groups_cache(vault.root(), &manifest); refresh_groups_cache(vault.root(), &manifest);
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()), commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Updated {}", item.id.as_str()); eprintln!("Updated {}", item.id.as_str());
Ok(()) Ok(())
@@ -1324,7 +1412,7 @@ fn cmd_rm(query: String) -> Result<()> {
manifest.upsert(&item); manifest.upsert(&item);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
refresh_groups_cache(vault.root(), &manifest); refresh_groups_cache(vault.root(), &manifest);
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()), commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Moved to trash: {}", item.title); eprintln!("Moved to trash: {}", item.title);
Ok(()) Ok(())
@@ -1342,7 +1430,7 @@ fn cmd_restore(query: String) -> Result<()> {
manifest.upsert(&item); manifest.upsert(&item);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
refresh_groups_cache(vault.root(), &manifest); refresh_groups_cache(vault.root(), &manifest);
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()), commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Restored: {}", item.title); eprintln!("Restored: {}", item.title);
Ok(()) Ok(())
@@ -1422,12 +1510,12 @@ fn cmd_backup_export(
let root = crate::helpers::vault_dir()?; let root = crate::helpers::vault_dir()?;
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3). // Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { let passphrase = if let Some(p) = test_backup_passphrase_override() {
Zeroizing::new(p) Zeroizing::new(p)
} else { } else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
}; };
let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() { let confirm = if test_backup_passphrase_override().is_some() {
passphrase.clone() passphrase.clone()
} else { } else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
@@ -1444,8 +1532,11 @@ fn cmd_backup_export(
.with_context(|| "failed to read .relicario/salt")?; .with_context(|| "failed to read .relicario/salt")?;
let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
.with_context(|| "failed to read .relicario/params.json")?; .with_context(|| "failed to read .relicario/params.json")?;
// devices.json was removed in the B1 security audit fix; fall back to
// an empty array so backups of post-B1 vaults still pack cleanly.
// Task 12 will remove the devices field from the backup format entirely.
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
.with_context(|| "failed to read .relicario/devices.json")?; .unwrap_or_else(|_| "[]".to_string());
let manifest_enc = fs::read(root.join("manifest.enc")) let manifest_enc = fs::read(root.join("manifest.enc"))
.with_context(|| "failed to read manifest.enc")?; .with_context(|| "failed to read manifest.enc")?;
let settings_enc = fs::read(root.join("settings.enc")) let settings_enc = fs::read(root.join("settings.enc"))
@@ -1569,6 +1660,7 @@ fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
use std::fs; use std::fs;
use relicario_core::backup; use relicario_core::backup;
use relicario_core::{ItemId, AttachmentId};
use zeroize::Zeroizing; use zeroize::Zeroizing;
let target = if target.is_absolute() { let target = if target.is_absolute() {
@@ -1591,7 +1683,7 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
.with_context(|| format!("failed to read backup file {}", input.display()))?; .with_context(|| format!("failed to read backup file {}", input.display()))?;
// Backup passphrase prompt. // Backup passphrase prompt.
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { let passphrase = if let Some(p) = test_backup_passphrase_override() {
Zeroizing::new(p) Zeroizing::new(p)
} else { } else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
@@ -1617,9 +1709,18 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
for item in &unpacked.items { for item in &unpacked.items {
let item_id = ItemId(item.id.clone());
if !item_id.is_valid() {
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
}
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
} }
for a in &unpacked.attachments { for a in &unpacked.attachments {
let item_id = ItemId(a.item_id.clone());
let att_id = AttachmentId(a.attachment_id.clone());
if !item_id.is_valid() || !att_id.is_valid() {
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
}
let dir = target.join("attachments").join(&a.item_id); let dir = target.join("attachments").join(&a.item_id);
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
@@ -1634,9 +1735,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
// .git/ history. // .git/ history.
if let Some(tar_bytes) = &unpacked.git_archive { if let Some(tar_bytes) = &unpacked.git_archive {
let mut archive = tar::Archive::new(tar_bytes.as_slice()); // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
archive.unpack(target.join(".git")) let cap = std::cmp::min(
.with_context(|| "failed to untar .git/")?; (tar_bytes.len() as u64).saturating_mul(100),
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
);
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
.with_context(|| "failed to safely unpack .git/ archive")?;
let git_dir = target.join(".git");
for (rel_path, body) in entries {
let dest = git_dir.join(&rel_path);
// Paranoid OS-level check even after textual validation in core.
if !dest.starts_with(&git_dir) {
anyhow::bail!(
"tar entry {} resolved outside .git/ (path traversal blocked)",
rel_path.display()
);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent {}", parent.display())
})?;
}
fs::write(&dest, &body).with_context(|| {
format!("write {}", dest.display())
})?;
}
} else { } else {
// No history bundled — start a fresh git repo. // No history bundled — start a fresh git repo.
let status = crate::helpers::git_command(&target, &["init"]).status()?; let status = crate::helpers::git_command(&target, &["init"]).status()?;
@@ -1799,6 +1923,28 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
let bytes = fs::read(&file) let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?; .with_context(|| format!("failed to read {}", file.display()))?;
// Check per-vault total attachment bytes cap (audit I3).
let current_total: u64 = manifest.items.values()
.flat_map(|e| &e.attachment_summaries)
.map(|s| s.size)
.sum();
let new_size = bytes.len() as u64;
let hard_cap = caps.per_vault_hard_cap_bytes;
let soft_cap = caps.per_vault_soft_cap_bytes;
if current_total + new_size > hard_cap {
anyhow::bail!(
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
current_total, new_size, hard_cap
);
}
if current_total + new_size > soft_cap {
eprintln!(
"warning: vault attachments will exceed soft cap ({} bytes)",
soft_cap
);
}
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name() let filename = file.file_name()
@@ -1831,7 +1977,9 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
]; ];
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
commit_paths(&vault, &format!("attach: {}{} ({})", commit_paths(&vault, &format!("attach: {}{} ({})",
file.display(), item.title, item.id.as_str()), &path_refs)?; crate::helpers::sanitize_for_commit(&file.display().to_string()),
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()), &path_refs)?;
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
Ok(()) Ok(())
} }
@@ -1842,7 +1990,7 @@ fn cmd_attachments(query: String) -> Result<()> {
let entry = resolve_query(&manifest, &query)?; let entry = resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?; let item = vault.load_item(&entry.id)?;
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
for a in &item.attachments { for a in &item.attachments {
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
} }
@@ -1914,7 +2062,7 @@ fn cmd_detach(query: String, aid: String) -> Result<()> {
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str()); let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
commit_paths( commit_paths(
&vault, &vault,
&format!("detach: {} from {} ({})", removed.filename, item.title, item.id.as_str()), &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&item_path, "manifest.enc", &blob_relpath], &[&item_path, "manifest.enc", &blob_relpath],
)?; )?;
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title); eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
@@ -2088,8 +2236,6 @@ fn cmd_sync() -> Result<()> {
} }
fn cmd_status() -> Result<()> { fn cmd_status() -> Result<()> {
use std::fs;
let vault = crate::session::UnlockedVault::unlock_interactive()?; let vault = crate::session::UnlockedVault::unlock_interactive()?;
let root = vault.root().to_path_buf(); let root = vault.root().to_path_buf();
let manifest = vault.load_manifest()?; let manifest = vault.load_manifest()?;
@@ -2102,16 +2248,6 @@ fn cmd_status() -> Result<()> {
.flat_map(|e| e.attachment_summaries.iter()) .flat_map(|e| e.attachment_summaries.iter())
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size)); .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
// devices.json — count entries; missing/empty → 0.
let devices_path = root.join(".relicario").join("devices.json");
let device_count = match fs::read(&devices_path) {
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.as_array().map(|a| a.len()))
.unwrap_or(0),
Err(_) => 0,
};
let last_commit = crate::helpers::git_command(&root, &[ let last_commit = crate::helpers::git_command(&root, &[
"log", "-1", "--pretty=format:%h %s", "log", "-1", "--pretty=format:%h %s",
]).output() ]).output()
@@ -2143,83 +2279,10 @@ fn cmd_status() -> Result<()> {
println!("Vault: {}", root.display()); println!("Vault: {}", root.display());
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
println!("Devices: {device_count}");
println!("Last commit: {last_commit}"); println!("Last commit: {last_commit}");
println!("Last export: {last_backup_str}"); println!("Last export: {last_backup_str}");
Ok(()) Ok(())
} }
fn cmd_device(action: DeviceAction) -> Result<()> {
use std::fs;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let root = crate::helpers::vault_dir()?;
let devices_path = root.join(".relicario").join("devices.json");
#[derive(serde::Serialize, serde::Deserialize)]
struct DeviceEntry { name: String, public_key: String }
match action {
DeviceAction::Add { name } => {
let mut existing: Vec<DeviceEntry> =
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
if existing.iter().any(|d| d.name == name) {
anyhow::bail!("device `{name}` already exists");
}
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let pubkey_hex = hex::encode(verifying.to_bytes());
existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() });
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
let cfg_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("no config dir"))?
.join("relicario").join("devices");
fs::create_dir_all(&cfg_dir)?;
let key_path = cfg_dir.join(format!("{name}.key"));
fs::write(&key_path, signing.to_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
let status = crate::helpers::git_command(&root,
&["add", ".relicario/devices.json"]).status()?;
if !status.success() { anyhow::bail!("git add failed"); }
let status = crate::helpers::git_command(&root,
&["commit", "-m", &format!("device: add {name}")]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
eprintln!("Added device `{name}` (pubkey: {pubkey_hex})");
}
DeviceAction::List => {
let existing: Vec<DeviceEntry> =
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); }
for d in existing {
println!("{:<20} {}", d.name, d.public_key);
}
}
DeviceAction::Revoke { name } => {
let mut existing: Vec<DeviceEntry> =
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
let before = existing.len();
existing.retain(|d| d.name != name);
if existing.len() == before { anyhow::bail!("device `{name}` not found"); }
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
let status = crate::helpers::git_command(&root,
&["add", ".relicario/devices.json"]).status()?;
if !status.success() { anyhow::bail!("git add failed"); }
let status = crate::helpers::git_command(&root,
&["commit", "-m", &format!("device: revoke {name}")]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
eprintln!("Revoked device `{name}`");
}
}
Ok(())
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct ParamsFile { struct ParamsFile {
format_version: u32, format_version: u32,
@@ -2261,3 +2324,318 @@ fn cmd_rate(passphrase: String) -> Result<()> {
println!("note: init requires score ≥ 3 (see `relicario init`)"); println!("note: init requires score ≥ 3 (see `relicario init`)");
Ok(()) Ok(())
} }
// ── Device management ─────────────────────────────────────────────────────────
/// Build a `GiteaClient` from flags or environment variables.
fn load_gitea_client(
gitea_url: Option<String>,
gitea_token: Option<String>,
owner: Option<String>,
repo: Option<String>,
) -> Result<crate::gitea::GiteaClient> {
let url = gitea_url
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
))?;
let token = gitea_token
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
))?;
let owner = owner
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
))?;
let repo = repo
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
))?;
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
}
fn cmd_device(action: DeviceAction) -> Result<()> {
use std::fs;
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
let root = crate::helpers::vault_dir()?;
let relicario_dir = root.join(".relicario");
let devices_path = relicario_dir.join("devices.json");
match action {
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
// Guard: don't overwrite an already-registered device name.
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
if existing.iter().any(|d| d.name == name) {
anyhow::bail!("a device named '{}' is already registered", name);
}
eprintln!("Generating signing keypair...");
let (signing_priv, signing_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
eprintln!("Generating deploy keypair...");
let (deploy_priv, deploy_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
// Optionally register deploy key with Gitea.
let gitea_key_id: u64 = if no_gitea {
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
0
} else {
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
let key_title = format!("relicario-{}", name);
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
client.create_deploy_key(&key_title, &deploy_pub)?
};
// Store keys locally with proper permissions.
crate::device::store_device_keys(
&name,
&signing_priv,
&signing_pub,
&deploy_priv,
&deploy_pub,
gitea_key_id,
)?;
// Mark as current device.
crate::device::set_current_device(&name)?;
// Configure git signing + SSH deploy key in the vault repo.
crate::device::configure_git_signing(&root, &name)?;
// Update devices.json.
let current_name = name.clone();
let mut devices = existing;
devices.push(DeviceEntry {
name: name.clone(),
public_key: signing_pub.clone(),
added_at: relicario_core::now_unix(),
added_by: current_name,
});
fs::create_dir_all(&relicario_dir)?;
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Commit the update.
let status = crate::helpers::git_command(
&root,
&["add", ".relicario/devices.json"],
)
.status()?;
if !status.success() {
anyhow::bail!("git add .relicario/devices.json failed");
}
let msg = format!("device: register {}", name);
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
.status()?;
if !status.success() {
anyhow::bail!("git commit failed");
}
eprintln!("Device '{}' registered.", name);
eprintln!("Signing public key:");
eprintln!(" {}", signing_pub);
if gitea_key_id != 0 {
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
}
Ok(())
}
DeviceAction::Revoke { name } => {
// Guard: refuse to revoke the currently active device (would lock
// the user out). They must add another device first.
if let Some(current) = crate::device::current_device()? {
if current == name {
anyhow::bail!(
"cannot revoke the current device '{}' — you would lose \
push access. Register another device first.",
name
);
}
}
// Load devices.json.
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let device = devices
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
.clone();
// Remove from devices.json.
devices.retain(|d| d.name != name);
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Append to revoked.json.
let revoked_path = relicario_dir.join("revoked.json");
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let revoked_by = crate::device::current_device()?
.unwrap_or_else(|| "unknown".to_string());
revoked.push(RevokedEntry {
name: name.clone(),
public_key: device.public_key.clone(),
revoked_at: relicario_core::now_unix(),
revoked_by,
});
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
// Delete deploy key from Gitea (best-effort — don't fail if it
// was already deleted or the config is missing).
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
if key_id != 0 {
// Build client from env vars only (no flags in revoke).
match load_gitea_client(None, None, None, None) {
Ok(client) => {
if let Err(e) = client.delete_deploy_key(key_id) {
eprintln!(
"warning: failed to delete Gitea deploy key {}: {}",
key_id, e
);
} else {
eprintln!("Deleted Gitea deploy key {}.", key_id);
}
}
Err(_) => {
eprintln!(
"warning: Gitea env vars not set — deploy key {} \
not deleted from Gitea.",
key_id
);
}
}
}
}
// Commit devices.json + revoked.json (always both — revoked.json
// was just written above so it is guaranteed to exist).
let add_args = [
"add",
".relicario/devices.json",
".relicario/revoked.json",
];
let status = crate::helpers::git_command(&root, &add_args).status()?;
if !status.success() {
anyhow::bail!("git add failed");
}
let msg = format!("device: revoke {}", name);
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
.status()?;
if !status.success() {
anyhow::bail!("git commit failed");
}
eprintln!("Device '{}' revoked.", name);
eprintln!("Revoked signing key: {}", device.public_key);
Ok(())
}
DeviceAction::List => {
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let current = crate::device::current_device()?.unwrap_or_default();
if devices.is_empty() {
println!("No registered devices.");
return Ok(());
}
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
println!("{}", "-".repeat(72));
for d in &devices {
let marker = if d.name == current { " *" } else { "" };
let added = crate::helpers::iso8601(d.added_at);
// Show only the first 40 chars of the public key line for readability.
let key_prefix: String = d.public_key.chars().take(40).collect();
println!("{:<20} {:<20} {}{}",
d.name, added, key_prefix, marker);
}
if !current.is_empty() {
println!("\n* = current device");
}
Ok(())
}
}
}
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -39,7 +39,7 @@ impl UnlockedVault {
.with_context(|| format!("failed to read reference image {}", image_path.display()))?; .with_context(|| format!("failed to read reference image {}", image_path.display()))?;
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?); let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { let passphrase = if let Some(p) = crate::test_passphrase_override() {
Zeroizing::new(p) Zeroizing::new(p)
} else { } else {
Zeroizing::new( Zeroizing::new(
@@ -50,7 +50,7 @@ impl UnlockedVault {
let master_key = derive_master_key( let master_key = derive_master_key(
passphrase.as_bytes(), passphrase.as_bytes(),
&*image_secret, &image_secret,
&salt, &salt,
&params, &params,
)?; )?;

View File

@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
// Encrypted blob file is gone. // Encrypted blob file is gone.
let blob_path = v.path() let blob_path = v.path()
.join("attachments") .join("attachments")
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or("")); .join("");
let item_attach_dir = std::fs::read_dir(v.path().join("attachments")) let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
.unwrap().next().unwrap().unwrap().path(); .unwrap().next().unwrap().unwrap().path();
let blob = item_attach_dir.join(format!("{aid}.enc")); let blob = item_attach_dir.join(format!("{aid}.enc"));

View File

@@ -8,7 +8,8 @@ fn init_creates_expected_layout() {
let v = TestVault::init(); let v = TestVault::init();
assert!(v.path().join(".relicario/salt").exists()); assert!(v.path().join(".relicario/salt").exists());
assert!(v.path().join(".relicario/params.json").exists()); assert!(v.path().join(".relicario/params.json").exists());
assert!(v.path().join(".relicario/devices.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("manifest.enc").exists());
assert!(v.path().join("settings.enc").exists()); assert!(v.path().join("settings.enc").exists());
assert!(v.path().join("reference.jpg").exists()); assert!(v.path().join("reference.jpg").exists());

View File

@@ -78,6 +78,7 @@ impl TestVault {
cmd.output().unwrap() cmd.output().unwrap()
} }
#[allow(dead_code)]
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap(); let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path()) cmd.current_dir(self.dir.path())
@@ -91,6 +92,7 @@ impl TestVault {
cmd.output().unwrap() cmd.output().unwrap()
} }
#[allow(dead_code)]
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap(); let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path()) cmd.current_dir(self.dir.path())

View File

@@ -66,7 +66,7 @@ fn generate_uses_vault_default_length() {
} }
#[test] #[test]
fn status_reports_item_attachment_and_device_counts() { fn status_reports_item_and_attachment_counts() {
let v = TestVault::init(); let v = TestVault::init();
v.run(&["add", "login", "--title", "active", v.run(&["add", "login", "--title", "active",
"--username", "u", "--password", "p"]); "--username", "u", "--password", "p"]);
@@ -99,8 +99,7 @@ fn status_reports_item_attachment_and_device_counts() {
assert!(lower.contains("attachment"), "missing attachment section: {stdout}"); assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}"); assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
// 0 devices in default test vault (init does not register one). // device count line removed — device key system was security theater (audit B1).
assert!(lower.contains("device"), "missing devices section: {stdout}");
// Last-commit line. // Last-commit line.
assert!( assert!(

View File

@@ -15,6 +15,7 @@ sha2 = "0.10"
sha1 = "0.10" sha1 = "0.10"
hmac = "0.12" hmac = "0.12"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] } image = { version = "0.25", default-features = false, features = ["jpeg"] }
# Typed-item additions # Typed-item additions
@@ -30,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false } tar = { version = "0.4", default-features = false }
base64 = "0.22" base64 = "0.22"
csv = "1" csv = "1"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies] [dev-dependencies]

View File

@@ -301,12 +301,20 @@ pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
} }
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> { 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)) let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?; .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]); let mut key = Zeroizing::new([0u8; 32]);
argon argon
.hash_password_into(passphrase, salt, key.as_mut_slice()) .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?; .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
Ok(key) Ok(key)
} }

View File

@@ -243,6 +243,23 @@ pub fn derive_master_key(
Ok(output) Ok(output)
} }
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
/// allowing callers to apply their own domain separation before KDF.
pub fn derive_master_key_raw(
input: &[u8],
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);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(input, salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -408,7 +425,7 @@ mod tests {
blob.extend_from_slice(&[0u8; 16]); blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]); let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
match err { match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => { RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01); assert_eq!(found, 0x01);

View File

@@ -0,0 +1,168 @@
//! 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())
}
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
/// `SHA256:<43-char base64 without padding>`.
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
use ssh_key::HashAlg;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
#[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());
}
#[test]
fn fingerprint_matches_ssh_keygen_format() {
let (_, public) = generate_keypair().unwrap();
let fp = fingerprint(&public).unwrap();
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
let body = fp.strip_prefix("SHA256:").unwrap();
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}
#[test]
fn fingerprint_is_deterministic() {
let (_, public) = generate_keypair().unwrap();
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}
#[test]
fn fingerprint_differs_per_key() {
let (_, p1) = generate_keypair().unwrap();
let (_, p2) = generate_keypair().unwrap();
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
}

View File

@@ -51,6 +51,10 @@ pub enum RelicarioError {
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")] #[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
BackupSchemaMismatch { found: u32, expected: u32 }, BackupSchemaMismatch { found: u32, expected: u32 },
/// An error during backup restore (e.g., tar safety validation failure).
#[error("backup restore: {0}")]
BackupRestore(String),
/// CSV header doesn't match the LastPass column layout. /// CSV header doesn't match the LastPass column layout.
#[error("unrecognized CSV header — expected LastPass export format ({0})")] #[error("unrecognized CSV header — expected LastPass export format ({0})")]
ImportCsvHeader(String), ImportCsvHeader(String),
@@ -109,6 +113,16 @@ pub enum RelicarioError {
/// rotating the passphrase or reference image. /// rotating the passphrase or reference image.
#[error("device key error: {0}")] #[error("device key error: {0}")]
DeviceKey(String), 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,
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
} }
/// Crate-wide result alias, reducing boilerplate in function signatures. /// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -2,8 +2,9 @@
//! //!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy) //! - `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). //! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` — //! - `AttachmentId` is the first 32 hex chars of `sha256(plaintext)` (128 bits)
//! content-addressed so identical plaintext blobs deduplicate naturally in git. //! 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::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
@@ -29,6 +30,12 @@ impl ItemId {
Self(hex::encode(bytes)) Self(hex::encode(bytes))
} }
pub fn as_str(&self) -> &str { &self.0 } 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 { impl Default for ItemId {
@@ -51,9 +58,15 @@ impl Default for FieldId {
impl AttachmentId { impl AttachmentId {
pub fn from_plaintext(plaintext: &[u8]) -> Self { pub fn from_plaintext(plaintext: &[u8]) -> Self {
let digest = Sha256::digest(plaintext); let digest = Sha256::digest(plaintext);
Self(hex::encode(&digest[..8])) Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
} }
pub fn as_str(&self) -> &str { &self.0 } 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)] #[cfg(test)]
@@ -106,12 +119,36 @@ mod tests {
} }
#[test] #[test]
fn attachment_id_is_16_hex_chars() { fn attachment_id_is_32_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes"); let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 16); assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); 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] #[test]
fn ids_serialize_as_bare_strings() { fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string()); let item = ItemId("abcdef0123456789".to_string());

View File

@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret. /// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
/// ceil(256 / 12) = 22 blocks per copy. /// ceil(256 / 12) = 22 blocks per copy.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
/// Mid-frequency DCT coefficient positions for embedding, specified as /// Mid-frequency DCT coefficient positions for embedding, specified as
/// (row, col) indices into the 8x8 DCT coefficient matrix. /// (row, col) indices into the 8x8 DCT coefficient matrix.
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
return None; return None;
} }
let mut block = [[0.0f64; 8]; 8]; let mut block = [[0.0f64; 8]; 8];
for row in 0..8 { for (row, block_row) in block.iter_mut().enumerate() {
for col in 0..8 { for (col, cell) in block_row.iter_mut().enumerate() {
block[row][col] = y.get(px + col, py + row); *cell = y.get(px + col, py + row);
} }
} }
Some(block) Some(block)
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { 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_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..8 { for (row, block_row) in block.iter().enumerate() {
for col in 0..8 { for (col, &cell) in block_row.iter().enumerate() {
y.set(start_x + col, start_y + row, block[row][col]); y.set(start_x + col, start_y + row, cell);
} }
} }
} }
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0. /// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
fn dct1d(input: &[f64; 8]) -> [f64; 8] { fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for k in 0..8 { for (k, out_k) in output.iter_mut().enumerate() {
let ck = if k == 0 { let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt() (1.0 / 8.0_f64).sqrt()
} else { } else {
(2.0 / 8.0_f64).sqrt() (2.0 / 8.0_f64).sqrt()
}; };
let mut sum = 0.0; let mut sum = 0.0;
for i in 0..8 { for (i, &x) in input.iter().enumerate() {
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
} }
output[k] = ck * sum; *out_k = ck * sum;
} }
output output
} }
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16) /// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
fn idct1d(input: &[f64; 8]) -> [f64; 8] { fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for i in 0..8 { for (i, out_i) in output.iter_mut().enumerate() {
let mut sum = 0.0; let mut sum = 0.0;
for k in 0..8 { for (k, &x) in input.iter().enumerate() {
let ck = if k == 0 { let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt() (1.0 / 8.0_f64).sqrt()
} else { } else {
(2.0 / 8.0_f64).sqrt() (2.0 / 8.0_f64).sqrt()
}; };
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
} }
output[i] = sum; *out_i = sum;
} }
output output
} }
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
/// ///
/// Pads the last byte with zeros if the bit count is not a multiple of 8. /// Pads the last byte with zeros if the bit count is not a multiple of 8.
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> { fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
for chunk in bits.chunks(8) { for chunk in bits.chunks(8) {
let mut byte = 0u8; let mut byte = 0u8;
for (i, &bit) in chunk.iter().enumerate() { for (i, &bit) in chunk.iter().enumerate() {

View File

@@ -52,26 +52,23 @@ pub enum TotpAlgorithm {
Sha512, Sha512,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TotpKind { pub enum TotpKind {
#[default]
Totp, Totp,
Hotp { counter: u64 }, Hotp { counter: u64 },
Steam, Steam,
} }
impl Default for TotpKind { /// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
/// ///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. /// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// For HOTP: uses the `counter` carried in the variant. /// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> { pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind { let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64, TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter, TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64, TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
}; };
let counter_bytes = counter.to_be_bytes(); let counter_bytes = counter.to_be_bytes();
@@ -165,7 +162,7 @@ mod tests {
} }
#[test] #[test]
fn hotp_carries_counter() { fn hotp_kind_roundtrips_through_json() {
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() }; let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap(); let json = serde_json::to_string(&cfg).unwrap();
let parsed: TotpConfig = serde_json::from_str(&json).unwrap(); let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
@@ -173,6 +170,18 @@ mod tests {
TotpKind::Hotp { counter } => assert_eq!(counter, 42), TotpKind::Hotp { counter } => assert_eq!(counter, 42),
other => panic!("expected Hotp, got {:?}", other), 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] #[test]

View File

@@ -83,3 +83,17 @@ pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupIt
pub mod import_lastpass; pub mod import_lastpass;
pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
pub mod device;
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};

View File

@@ -0,0 +1,129 @@
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
impl RecoveryQrPayload {
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
&self.bytes
}
}
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let nfc: String = passphrase.nfc().collect();
let nfc_bytes = nfc.as_bytes();
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn production_params() -> KdfParams {
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let input = recovery_kdf_input(passphrase);
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
}
#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
passphrase: &str,
image_secret: &[u8; 32],
params: &KdfParams,
) -> Result<RecoveryQrPayload> {
let mut kdf_salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
let mut wrap_nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
let mut bytes = [0u8; PAYLOAD_LEN];
let mut pos = 0;
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
bytes[pos] = VERSION; pos += 1;
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
bytes[pos..pos+48].copy_from_slice(&ciphertext);
Ok(RecoveryQrPayload { bytes })
}
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
}
#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
payload_bytes: &[u8],
passphrase: &str,
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
if payload_bytes.len() != PAYLOAD_LEN {
return Err(RelicarioError::RecoveryQr(
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
));
}
if &payload_bytes[0..4] != MAGIC {
return Err(RelicarioError::RecoveryQr("bad magic".into()));
}
if payload_bytes[4] != VERSION {
return Err(RelicarioError::RecoveryQr(
format!("unsupported version 0x{:02x}", payload_bytes[4])
));
}
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().expect("slice length validated above");
let wrap_nonce = &payload_bytes[37..61];
let ciphertext = &payload_bytes[61..109];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&plaintext);
Ok(out)
}
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
code.render::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build()
}

View File

@@ -0,0 +1,138 @@
//! Safe tar unpacking for backup restore.
//!
//! The standard `tar::Archive::unpack` has no guards against path traversal,
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
//! with `safe_unpack_git_archive`, which validates every entry before returning
//! `(relative_path, bytes)` pairs to the caller.
use std::io::Read;
use std::path::{Component, PathBuf};
use tar::EntryType;
use crate::error::{RelicarioError, Result};
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
/// regular files only.
///
/// # Errors
///
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
///
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
/// - An entry is a symlink or hardlink — "symlink/link rejected".
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
pub fn safe_unpack_git_archive(
tar_bytes: &[u8],
max_uncompressed_bytes: u64,
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
let mut archive = tar::Archive::new(tar_bytes);
let entries = archive
.entries()
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut cumulative: u64 = 0;
for entry in entries {
let mut entry = entry.map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
})?;
let header = entry.header();
let entry_type = header.entry_type();
// Reject symlinks and hardlinks.
match entry_type {
EntryType::Symlink => {
return Err(RelicarioError::BackupRestore(
"symlink entry rejected".to_string(),
));
}
EntryType::Link => {
return Err(RelicarioError::BackupRestore(
"hardlink entry rejected".to_string(),
));
}
EntryType::Directory => {
// Directories are implicit — skip without reading body.
continue;
}
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
// These are normal file types; fall through to path checks.
}
_ => {
return Err(RelicarioError::BackupRestore(format!(
"unexpected entry type: {:?}",
entry_type
)));
}
}
// Validate the path.
let path = entry.path().map_err(|e| {
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
})?;
let path = path.into_owned();
for component in path.components() {
match component {
Component::ParentDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry contains '..' component".to_string(),
));
}
Component::RootDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has absolute path".to_string(),
));
}
Component::Prefix(_) => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has Windows drive prefix".to_string(),
));
}
Component::Normal(_) | Component::CurDir => {
// Acceptable components.
}
}
}
// Check declared size before reading body.
let claimed = header.size().map_err(|e| {
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
})?;
if claimed > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
)));
}
let new_total = cumulative.saturating_add(claimed);
if new_total > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
)));
}
// Read the file body.
let mut body = Vec::with_capacity(claimed as usize);
entry.read_to_end(&mut body).map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
})?;
cumulative += body.len() as u64;
result.push((path, body));
}
Ok(result)
}

View File

@@ -19,7 +19,7 @@ impl MonthYear {
if !(1..=12).contains(&month) { if !(1..=12).contains(&month) {
return Err("month must be 1..=12"); return Err("month must be 1..=12");
} }
if year < 2000 || year > 2099 { if !(2000..=2099).contains(&year) {
return Err("year must be 2000..=2099"); return Err("year must be 2000..=2099");
} }
Ok(Self { month, year }) Ok(Self { month, year })

View File

@@ -186,3 +186,30 @@ fn tampered_ciphertext_rejected_as_decrypt_error() {
other => panic!("expected Decrypt for tampered tag, got {other:?}"), 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,60 @@
use relicario_core::{
crypto::KdfParams,
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
fn test_secret() -> [u8; 32] {
let mut s = [0u8; 32];
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
s
}
#[test]
fn roundtrip_recovers_image_secret() {
let passphrase = "correct-horse-battery-staple";
let secret = test_secret();
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
.expect("generate ok");
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
.expect("unwrap ok");
assert_eq!(recovered.as_ref(), &secret);
}
#[test]
fn wrong_passphrase_fails_decrypt() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
.expect("generate ok");
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
assert!(result.is_err());
}
#[test]
fn payload_is_109_bytes() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
assert_eq!(payload.as_bytes().len(), 109);
}
#[test]
fn svg_output_is_non_empty_xml() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
let svg = recovery_qr_to_svg(&payload);
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
assert!(!svg.is_empty());
}
#[test]
fn bad_magic_returns_error() {
let mut bad = [0u8; 109];
bad[0..4].copy_from_slice(b"NOPE");
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
assert!(result.is_err());
}

View File

@@ -0,0 +1,187 @@
use std::path::PathBuf;
use tar::{Builder, Header, EntryType};
use relicario_core::safe_unpack_git_archive;
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
/// manually to produce truly malicious archives.
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
let mut buf = vec![0u8; 512]; // one header block
// Bytes 0-99: name field (null-padded)
let name_len = raw_path.len().min(100);
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
// Bytes 100-107: mode = "0000644\0"
buf[100..108].copy_from_slice(b"0000644\0");
// Bytes 108-115: uid
buf[108..116].copy_from_slice(b"0000000\0");
// Bytes 116-123: gid
buf[116..124].copy_from_slice(b"0000000\0");
// Bytes 124-135: size (octal, 11 digits + null)
let size_str = format!("{:011o}\0", content.len());
buf[124..136].copy_from_slice(size_str.as_bytes());
// Bytes 136-147: mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// Bytes 148-155: checksum placeholder (spaces during compute)
buf[148..156].copy_from_slice(b" ");
// Byte 156: typeflag = '0' (regular file)
buf[156] = b'0';
// Bytes 257-262: magic "ustar\0"
buf[257..263].copy_from_slice(b"ustar\0");
// Bytes 263-264: version "00"
buf[263..265].copy_from_slice(b"00");
// Compute checksum (sum of all bytes, checksum field treated as spaces).
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
// Append padded content blocks.
let mut out = buf;
if !content.is_empty() {
out.extend_from_slice(content);
// Pad to 512-byte boundary.
let remainder = content.len() % 512;
if remainder != 0 {
out.extend(vec![0u8; 512 - remainder]);
}
}
// Two zero blocks = end-of-archive.
out.extend(vec![0u8; 1024]);
out
}
/// Build a tar with a raw symlink entry (typeflag = '2').
fn raw_symlink_tar() -> Vec<u8> {
let mut buf = vec![0u8; 512];
// name
buf[..9].copy_from_slice(b"evil_link");
// mode
buf[100..108].copy_from_slice(b"0000755\0");
// uid/gid
buf[108..116].copy_from_slice(b"0000000\0");
buf[116..124].copy_from_slice(b"0000000\0");
// size = 0
buf[124..136].copy_from_slice(b"00000000000\0");
// mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// checksum placeholder
buf[148..156].copy_from_slice(b" ");
// typeflag = '2' (symlink)
buf[156] = b'2';
// linkname
let target = b"/etc/passwd";
buf[157..157 + target.len()].copy_from_slice(target);
// magic
buf[257..263].copy_from_slice(b"ustar\0");
buf[263..265].copy_from_slice(b"00");
// Compute checksum.
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
let mut out = buf;
out.extend(vec![0u8; 1024]); // end-of-archive
out
}
fn build_normal_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = b"hello";
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
.unwrap();
builder.finish().unwrap();
}
buf
}
fn build_oversize_tar() -> Vec<u8> {
// Actual 2048-byte body; test will use cap=1024
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = vec![0u8; 2048];
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "bigfile.bin", content.as_slice())
.unwrap();
builder.finish().unwrap();
}
buf
}
#[test]
fn restore_rejects_path_traversal() {
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains(".."),
"got: {msg}"
);
}
#[test]
fn restore_rejects_absolute_path() {
// Craft a tar with "/etc/escaped.txt" using raw bytes.
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains("absolute"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_symlink() {
let bytes = raw_symlink_tar();
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("symlink") || msg.contains("link"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_size_bomb() {
let bytes = build_oversize_tar(); // actual 2048-byte entry
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
let msg = format!("{err:#}");
assert!(
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
"got: {msg}"
);
}
#[test]
fn restore_accepts_normal_files() {
let buf = build_normal_tar();
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
assert_eq!(entries[0].1, b"hello");
}

View File

@@ -0,0 +1,18 @@
[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"
tempfile = "3"
regex = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

View File

@@ -0,0 +1,189 @@
//! relicario-server -- pre-receive hook for signature verification.
use std::fs;
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<()> {
let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json,
Err(_) => {
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
return Ok(());
}
};
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?;
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
// True bootstrap: no devices ever registered and none revoked.
if devices.is_empty() && revoked.is_empty() {
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
return Ok(());
}
// Build temp allowed-signers file from registered devices.
let tmp = tempfile::tempdir().context("create tempdir")?;
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for d in &devices {
allowed_body.push_str("relicario ");
allowed_body.push_str(d.public_key.trim());
allowed_body.push('\n');
}
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
// Run git verify-commit --raw. Capture both exit code and stderr.
// NOTE: we do NOT short-circuit on non-zero exit here because even for
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
let output = Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.context("git verify-commit")?;
let stderr = String::from_utf8_lossy(&output.stderr);
// Parse the SHA-256 fingerprint from stderr.
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
Some(m) => m.as_str().to_string(),
None => {
// No fingerprint in stderr = unsigned or completely malformed signature.
eprintln!(
"REJECT: commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Build fingerprint → entry maps.
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
std::collections::HashMap::new();
for d in &devices {
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
device_by_fp.insert(fp, d);
}
}
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
std::collections::HashMap::new();
for r in &revoked {
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
revoked_by_fp.insert(fp, r);
}
}
// Get committer date (NOT author date).
let ct_out = Command::new("git")
.args(["show", "-s", "--format=%ct", commit])
.output()
.context("git show committer date")?;
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
.trim()
.parse()
.context("parse committer timestamp")?;
// Check revocation FIRST (revoked entries may not be in devices anymore).
if let Some(r) = revoked_by_fp.get(&signing_fp) {
if committer_ts >= r.revoked_at {
eprintln!(
"REJECT: commit {commit} — signed by revoked device '{}' \
(committer ts {committer_ts} >= revoked_at {})",
r.name, r.revoked_at
);
std::process::exit(1);
}
// Historical commit: committer_ts < revoked_at → was valid when signed.
eprintln!(
"OK: commit {commit} — historical commit signed by '{}' before revocation",
r.name
);
return Ok(());
}
// Not revoked — must be in active devices.
if !device_by_fp.contains_key(&signing_fp) {
eprintln!(
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
);
std::process::exit(1);
}
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
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,230 @@
//! Acceptance tests for `relicario-server verify-commit`.
//!
//! Four scenarios from audit S1:
//! 1. Registered non-revoked key → exit 0
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
let priv_path = dir.join(format!("{name}.key"));
let pub_path = dir.join(format!("{name}.pub"));
fs::write(&priv_path, priv_pem.as_str()).unwrap();
fs::write(&pub_path, &pub_line).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
}
(priv_path, pub_path, pub_line)
}
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
let mut cmd = Command::new("git");
cmd.current_dir(repo).args(args);
for (k, v) in extra_env {
cmd.env(k, v);
}
let status = cmd.status().expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(repo: &Path) {
git(repo, &["init", "-q", "-b", "main"], &[]);
git(repo, &["config", "user.email", "test@test"], &[]);
git(repo, &["config", "user.name", "test"], &[]);
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
}
fn sign_commit(
repo: &Path,
signing_key: &Path,
allowed_signers: &Path,
committer_unix: i64,
msg: &str,
file_path: &str,
file_content: &str,
) -> String {
fs::write(repo.join(file_path), file_content).unwrap();
git(repo, &["add", file_path], &[]);
let date = format!("@{committer_unix} +0000");
git(
repo,
&[
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", signing_key.display()),
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
"commit", "-S", "-q", "-m", msg,
],
&[
("GIT_AUTHOR_DATE", &date),
("GIT_COMMITTER_DATE", &date),
],
);
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
let dir = repo.join(".relicario");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
git(repo, &["add", ".relicario"], &[]);
git(repo, &["commit", "-q", "-m", "device files"], &[]);
}
#[test]
fn registered_non_revoked_key_accepted() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}
#[test]
fn unregistered_key_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (_, _, pub_a) = write_keypair(repo, "alice");
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
// Only Alice is registered.
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
// Evil signs against a file containing both keys so git commit signing works,
// but the binary's allowed-signers (from devices.json) only has Alice.
let allowed = repo.join("test_allowed_signers");
fs::write(
&allowed,
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
)
.unwrap();
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("unregistered"));
}
#[test]
fn revoked_key_after_revoked_at_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated AFTER revocation.
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("revoked"));
}
#[test]
fn revoked_key_before_revoked_at_accepted_historical() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Same as above: Alice only in revoked.json.
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated BEFORE revocation -- historical case must pass.
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}

View File

@@ -19,6 +19,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
base64 = "0.22" base64 = "0.22"
hex = "0.4" hex = "0.4"
rand = "0.8" rand = "0.8"
once_cell = "1"
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3" wasm-bindgen-test = "0.3"

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

@@ -5,8 +5,10 @@
//! looked up per call via a u32 handle. JS cannot read key bytes. //! looked up per call via a u32 handle. JS cannot read key bytes.
mod session; mod session;
mod device;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams}; use relicario_core::{derive_master_key, imgsecret, KdfParams};
@@ -35,7 +37,8 @@ pub fn unlock(
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params) let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?; .map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key); let stored_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_secret);
Ok(SessionHandle(handle)) Ok(SessionHandle(handle))
} }
@@ -206,26 +209,53 @@ pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
})) }))
} }
use ed25519_dalek::SigningKey; /// Register a new device, generating ed25519 keypairs for signing and deploy.
use base64::Engine; /// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
/// Private keys are kept internal to WASM and never cross to JS.
/// Generate an ed25519 keypair for device registration.
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
#[wasm_bindgen] #[wasm_bindgen]
pub fn generate_device_keypair() -> Result<JsValue, JsError> { pub fn register_device(name: &str) -> Result<JsValue, JsError> {
let mut rng = rand::thread_rng(); let (signing_pub, deploy_pub) =
let signing_key = SigningKey::generate(&mut rng); device::register_device(name).map_err(|e| JsError::new(&e))?;
let verifying_key = signing_key.verifying_key();
let public_hex = hex::encode(verifying_key.as_bytes());
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
js_value_for(&serde_json::json!({ js_value_for(&serde_json::json!({
"public_key_hex": public_hex, "signing_public_key": signing_pub,
"private_key_base64": private_b64, "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. /// Extract field history from a decrypted item JSON.
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] } /// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
#[wasm_bindgen] #[wasm_bindgen]
@@ -307,6 +337,8 @@ pub fn totp_compute(
// ── Backup container bridge ───────────────────────────────────────────────── // ── Backup container bridge ─────────────────────────────────────────────────
use base64::Engine;
use relicario_core::backup::{ use relicario_core::backup::{
pack_backup as core_pack_backup, pack_backup as core_pack_backup,
unpack_backup as core_unpack_backup, unpack_backup as core_unpack_backup,
@@ -454,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
Ok(json.to_string()) Ok(json.to_string())
} }
// ── Recovery QR bindings ─────────────────────────────────────────────────────
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
#[cfg(test)] #[cfg(test)]
mod session_tests { mod session_tests {
use super::*; use super::*;
@@ -462,7 +527,7 @@ mod session_tests {
#[test] #[test]
fn insert_then_remove_clears_entry() { fn insert_then_remove_clears_entry() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32])); let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
assert_ne!(h, 0); assert_ne!(h, 0);
assert!(session::remove(h)); assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false assert!(!session::remove(h)); // second remove false
@@ -471,7 +536,7 @@ mod session_tests {
#[test] #[test]
fn with_yields_key_only_while_session_lives() { fn with_yields_key_only_while_session_lives() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32])); let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
let byte = session::with(h, |k| k[0]); let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22)); assert_eq!(byte, Some(0x22));
session::remove(h); session::remove(h);
@@ -483,7 +548,7 @@ mod session_tests {
fn manifest_round_trip_via_handle() { fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest}; use relicario_core::{Manifest, decrypt_manifest};
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32])); let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
let handle = SessionHandle(h); let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]); let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new(); let empty = Manifest::new();

View File

@@ -6,12 +6,17 @@ use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! { thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new()); static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) }; static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
} }
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| { let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut(); let mut n = n.borrow_mut();
let h = *n; let h = *n;
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h h
}); });
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle handle
} }
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R> pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R, F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{ {
SESSIONS.with(|s| s.borrow().get(&handle).map(f)) SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
} }
pub fn remove(handle: u32) -> bool { pub fn remove(handle: u32) -> bool {

View File

@@ -42,15 +42,19 @@
┌──────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────┐
│ GIT SERVER (untrusted) │ │ GIT SERVER (untrusted) │
│ │ │ │
│ relicario-vault.git/ │ relicario-vault.git/ │
│ ├── manifest.enc ← opaque ciphertext │ │ ├── manifest.enc ← opaque ciphertext
│ ├── entries/ │ ├── settings.enc ← opaque ciphertext
│ ├── a1b2c3d4.enc ← opaque ciphertext ├── items/
│ │ ── e5f6a7b8.enc ← opaque ciphertext │ │ ── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
└── .relicario/ │ └── …
│ ├── attachments/ │
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
│ └── .relicario/ │
│ ├── salt ← 32 bytes (not secret) │ │ ├── salt ← 32 bytes (not secret) │
│ ├── params.json ← KDF params (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, │ │ The server sees NOTHING useful. No keys, no plaintext, │
│ no metadata about what's inside. │ │ no metadata about what's inside. │
@@ -79,8 +83,9 @@ vault_salt ────────►│ │
┌──────────────────┐ ┌──────────────────┐
master_key ────────►│ XChaCha20- │──────► manifest.enc master_key ────────►│ XChaCha20- │──────► manifest.enc
empty manifest ────►│ Poly1305 │ empty manifest ────►│ Poly1305 │ settings.enc
└──────────────────┘ default settings ──►│ encrypt (×2) (parallel artifacts;
└──────────────────┘ independent nonces)
┌──────────────────┐ ┌──────────────────┐
│ git init │──────► vault repo │ git init │──────► vault repo
@@ -88,6 +93,14 @@ empty manifest ────►│ Poly1305 │
└──────────────────┘ └──────────────────┘
``` ```
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
attachment encryption, and field-history tracking are not shown above —
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
The flow above covers only the crypto-pipeline shape that vault init
establishes; the per-item lifecycle reuses the same `master_key` +
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
`attachments/<item-id>/<aid>.enc`.
## Unlock Flow (every vault operation) ## Unlock Flow (every vault operation)
``` ```
@@ -217,21 +230,23 @@ Input JPEG (possibly re-encoded or cropped)
│ uses │ uses
┌────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────┐
│ relicario-core │ relicario-core │
│ Platform-agnostic: bytes in, bytes out │ │ Platform-agnostic: bytes in, bytes out │
│ No filesystem, no network, no git │ │ No filesystem, no network, no git │
│ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐
│ │ crypto │ │ imgsecret│ │ entry │ │ vault │ │ │ │ crypto │ │ imgsecret│ │ item + │ │ vault │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ types │ │ │
│ │ KDF │ │ DCT │ │ Entry │ │ encrypt_ │ │ │ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │
│ │ encrypt │ │ embed │ │ Manifest│ │ entry() │ │ encrypt │ │ embed │ │ Manifest│ │ item()
│ │ decrypt │ │ extract │ │ search │ │ decrypt_ │ │ │ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │
│ │ │ │ QIM │ │ │ │ manifest() │ │ │ │ │ │ QIM │ │ Backup │ │ manifest() │
└──────────┘ └──────────┘ └─────────┘ └────────────┘ │ │ │ │ │ Device │ │ ... │
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
│ │ │ │
Future: relicario-wasm wraps this for browser extension Consumed by: relicario-cli, relicario-wasm (extension),
Future: JNI/Swift wrappers for Android/iOS relicario-server (pre-receive hook).
│ Future: JNI/Swift wrappers for Android/iOS. │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```

104
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,104 @@
# 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
Enforcement requires deploying the `relicario-server` pre-receive hook
on the vault remote. The crate provides two subcommands:
- `relicario-server generate-hook` — emits the hook script to install at
`<repo>/hooks/pre-receive`
- `relicario-server verify-commit <sha>` — checks one commit's signature
against `.relicario/devices.json` and `.relicario/revoked.json` as of
that commit; the hook calls this for every pushed ref
Without the server hook, signed commits provide authorship metadata only
— any process with push access can land an unsigned commit, since
verification is otherwise advisory.
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 is optional but recommended for shared vaults.
## Configuration env vars
Relicario reads the following environment variables. Each is a trust
boundary: an attacker who can set them in the user's environment can
influence Relicario's behavior. They are listed here for security
reviewers to audit the surface in one place.
### User-facing (active in all builds)
| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
### Debug-only (compiled out of `cargo build --release`)
The following variables are gated behind `cfg(debug_assertions)` and
are **no-ops** in release builds. The env-var lookup is removed by the
optimiser from any binary built without debug assertions (i.e. the
standard `--release` profile).
| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |

View File

@@ -1,6 +1,6 @@
# Architecture overview — Relicario # 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. This is the cross-codebase entry point. It describes how the four 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: > If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
> >
@@ -10,44 +10,48 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
> >
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*. > If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
## The three codebases ## The four codebases
``` ```
┌─────────────────────┐ ┌─────────────────────┐
│ relicario-core │ │ relicario-core │
│ (Rust, no I/O) │ │ (Rust, no I/O) │
│ crypto · items │ │ crypto · items │
│ manifest · stego │ │ manifest · stego │
└──────────┬──────────┘ │ device keys + fp │
└──┬───────────┬──────┘
┌─────────────────────┼─────────────────────┐ │ │
│ │ │ ┌────────────────┼───────────┴──────┬────────────────────┐
┌────────────────┐ ┌────────────────────┐ (compiled to WASM ▼ ▼ ▼ ▼
│ relicario-cli │ │ relicario-wasm │ inside the ) ┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
(Rust binary) │ │ (#[wasm_bindgen] │ extension relicario-cli │ │ relicario-server │ │ relicario-wasm
bindings) │ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen]
filesystem + │ │ │ │ │ │ │ bindings)
git + │ └────────┬───────────┘ filesystem + │ │ pre-receive hook │ │
clap UX git + │ │ verify-commit + │ │ compiled to WASM
└────────────────┘ ▼ │ clap UX │ │ generate-hook │ │ for the extension
┌─────────────────────┐ │ └────────────────┘ └──────────────────┘ └───────────────────
extension
(TypeScript) │
popup · vault ┌─────────────────────┐
setup · content │ extension
service worker │ (TypeScript)
└─────────────────────┘ │ popup · vault │
│ setup · content │
│ service worker │
└─────────────────────┘
``` ```
| Codebase | Language | Role | Key boundary | | 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-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. 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-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. | | `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
| `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. | | `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. 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. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
## Inter-codebase contracts ## Inter-codebase contracts
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
| Target | Tool | Output | When to run | | Target | Tool | Output | When to run |
|---|---|---|---| |---|---|---|---|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution | | Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
| Native test suites | `cargo test` (workspace) | — | After any Rust change | | 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 | | 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 | | Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
@@ -177,8 +182,8 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|---|---|---| |---|---|---|
| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack | | 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 | | AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly |
| Item IDs are random 8-char hex | `core/ids.rs` | Stable, short, no information leak | | Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak |
| Attachment IDs are content-addressed (SHA-256) | `core/ids.rs` | Dedup; integrity check | | 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 | | 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 | | 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 | | Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log |
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/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 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 | | A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
| 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` | | 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) | | 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) | | Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |

View File

@@ -0,0 +1,165 @@
# Multi-Agent Development Paradigm
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
---
## Overview
| Role | Terminal | Branch | Responsibilities |
|------|----------|--------|-----------------|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
---
## Starting a lift
### Prerequisites
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
- [ ] No uncommitted changes in main that would confuse the devs
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
### Launch sequence
```bash
# 1. Start the relay server (this terminal becomes the relay log)
tools/relay/start.sh # prints copy-paste instructions, then starts server
# Optional: use a multiplexer to auto-open all four terminals
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
```
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
---
## Coordination protocol
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
| Kind | Block header | When used |
|------|-------------|-----------|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
| `free` | (none) | Ad-hoc messages not covered by the above |
A well-formed `status` block:
```
## STATUS UPDATE — DEV-B
Time: 2026-05-02T14:30:00-07:00
Branch: feature/v0.5.0-plan-b-extension-ux
Task: P4 / error-copy map
Status: DONE
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
Tests: green
Notes: No issues. Ready for PM review of P4 before starting B1.
```
---
## Using the relay tools
All three Claude Code sessions have these tools available when the relay server is running:
```
post_message(from, to, kind, body) → { id }
read_messages(for) → RelayMessage[] (drains inbox)
list_pending(for) → { count, kinds } (non-destructive)
```
Typical dev flow per task:
```
1. read_messages(for="dev-b") # check for directives before starting
2. ... do the work ...
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
```
Typical PM flow:
```
1. read_messages(for="pm") # see what devs posted
2. ... review ...
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
```
---
## If the relay server isn't running
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
To restart a crashed server mid-lift:
```bash
tools/relay/start.sh
```
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
---
## Generating kickoff prompts
### Full workflow (spec → plans → kickoff)
**Step 1 — Write a spec**
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
**Step 2 — Invoke the kickoff skill**
Say anything like:
- "kick off the multi-agent thing for v0.6.0"
- "spin up PM and devs for this release"
- "set up the three-terminal paradigm"
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
4. Commit the prompts and print launch instructions
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
**Step 3 — Launch**
```bash
tools/relay/start.sh # prints prompt file paths, starts relay server
# open N+1 terminals, paste each prompt below its '---' line
```
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
---
## Ending a lift
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
3. PM tags the release (only after explicit user `yes`)
4. Ctrl-C the relay terminal — all in-memory messages are discarded
---
## Roles and boundaries (quick reference)
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
**User must:** authorize all merges and the release tag. Everything else is delegated.

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,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,178 @@
# Documentation Audit — 2026-05-02
Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
## Summary
- **Total findings:** 14
- **Fixed inline (initial pass):** 6
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
- **No action needed:** 0
- **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:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
---
### 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:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
---
### 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:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
---
### 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:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "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 over the plaintext (128 bits)."
---
### 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:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
---
### 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:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
---
### 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:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
---
### 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:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
---
### 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
### Initial pass (commit `900ccf1`):
- `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).
### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
No source files, `Cargo.lock`, or extension code were modified at any point.

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.

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,165 @@
# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR
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.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four 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-03. 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-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec
3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout)
4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX)
5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR)
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from all three feature branches
- **Drive the interface contract** between B and C (see below) — this is your first hands-on action
- Write the `CHANGELOG.md` entry for v0.5.1
- Tag `v0.5.1` 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.
## Stream overview
| Stream | Branch | Owner | Core files |
|--------|--------|-------|-----------|
| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` |
| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) |
| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` |
## Interface contracts (enforce before work starts)
### AB: Settings component signature
DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work.
**Agreed interface** (post to both devs as your first directive):
```ts
// extension/src/popup/components/settings.ts
/**
* Render the full sectioned settings view into `container`.
* May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup).
*/
export async function renderSettings(container: HTMLElement): Promise<void>;
/**
* Teardown: close any open generator panel, remove keyboard listeners.
* Call before navigating away from the settings view.
*/
export function teardownSettings(): void;
```
DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts.
DEV-B exports these names with these exact signatures.
### BC: Security section component signature
DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts).
**Agreed interface** (post to both devs as your first directive):
```ts
// extension/src/popup/components/settings-security.ts
/**
* Render the three-state Recovery QR + trusted devices security section
* into `container`. `sessionHandle` is the current WASM session handle value
* (from the service-worker's session), or null if the vault is locked.
*/
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void>;
/**
* Teardown: remove any event listeners attached during render.
*/
export function teardownSecuritySection(): void;
```
DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation.
## Merge order and strategy
1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY.
2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR.
3. No squash merges — git history is preserved per project rule.
4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`.
## Coordination protocol
You are one of four terminals. The user relays messages.
**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks.
**You emit:** a `## DIRECTIVE TO DEV-X` block. Format:
```
## DIRECTIVE TO DEV-A (or B or C)
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:
```
## RELEASE STATUS — v0.5.1
Dev A: <task X of Y, status>
Dev B: <task X of Y, status>
Dev C: <task X of Y, status>
PM: <current action>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev C REVIEW-READY", "all three merged">
```
## 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 diff against the spec sections owned by that stream
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge`
5. If red: post `Action: HOLD` with specific concerns
## Pre-tag checklist
Before tagging v0.5.1:
- [ ] `feature/v0.5.1-stream-a-layout` merged to main
- [ ] `feature/v0.5.1-stream-b-settings` merged to main
- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main
- [ ] `cargo test` green on main
- [ ] `bun run test` green (extension)
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
- [ ] `bun run build` + `bun run build:firefox` clean (extension)
- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`)
- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `&#x2934;` anywhere
- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls)
- [ ] Settings left-nav sections all render without console errors
- [ ] `CHANGELOG.md` entry for v0.5.1 written
- [ ] Explicit user approval to tag
## First action
After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the AB and BC interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,956 @@
# Relay Server 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 local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
---
## File map
| Action | Path | Responsibility |
|--------|------|----------------|
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
---
## Task 1: Scaffold `tools/relay/`
**Files:**
- Create: `tools/relay/package.json`
- Create: `tools/relay/tsconfig.json`
- Modify: `.gitignore`
- [ ] **Step 1: Create `tools/relay/package.json`**
```json
{
"name": "@relicario/relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx server.ts",
"test": "node --import=tsx/esm --test queue.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}
```
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true
},
"include": ["*.ts"]
}
```
- [ ] **Step 3: Add to root `.gitignore`**
Open `/home/alee/Sources/relicario/.gitignore` and append:
```
tools/relay/node_modules/
```
- [ ] **Step 4: Install dependencies and verify**
```bash
cd tools/relay && npm install
```
Expected: `node_modules/` created, no errors. Verify with:
```bash
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
```
Expected: both directories exist.
- [ ] **Step 5: Commit scaffold**
```bash
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
```
---
## Task 2: `queue.ts` — TDD
**Files:**
- Create: `tools/relay/queue.ts`
- Create: `tools/relay/queue.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tools/relay/queue.test.ts`:
```typescript
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RelayQueue, isRole } from "./queue.ts";
describe("RelayQueue", () => {
let q: RelayQueue;
beforeEach(() => {
q = new RelayQueue();
});
it("post + read roundtrip returns the message with correct fields", () => {
q.post("dev-b", "pm", "status", "Task P4 DONE");
const msgs = q.read("pm");
assert.equal(msgs.length, 1);
assert.equal(msgs[0].from, "dev-b");
assert.equal(msgs[0].to, "pm");
assert.equal(msgs[0].kind, "status");
assert.equal(msgs[0].body, "Task P4 DONE");
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
assert.ok(typeof msgs[0].ts === "string");
});
it("consume-once: second read returns empty", () => {
q.post("dev-a", "pm", "question", "Should I use approach A?");
q.read("pm");
const second = q.read("pm");
assert.deepEqual(second, []);
});
it("list_pending does not drain inbox", () => {
q.post("dev-b", "pm", "directive", "PROCEED");
const before = q.pending("pm");
assert.equal(before.count, 1);
const after = q.read("pm");
assert.equal(after.length, 1);
});
it("FIFO ordering across multiple senders", () => {
q.post("dev-a", "pm", "status", "first");
q.post("dev-b", "pm", "status", "second");
q.post("dev-a", "pm", "question", "third");
const msgs = q.read("pm");
assert.equal(msgs.length, 3);
assert.equal(msgs[0].body, "first");
assert.equal(msgs[1].body, "second");
assert.equal(msgs[2].body, "third");
});
it("isRole rejects unknown strings", () => {
assert.ok(isRole("pm"));
assert.ok(isRole("dev-a"));
assert.ok(isRole("dev-b"));
assert.ok(!isRole("dev-c"));
assert.ok(!isRole(""));
assert.ok(!isRole("PM"));
});
});
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
- [ ] **Step 3: Write `queue.ts`**
Create `tools/relay/queue.ts`:
```typescript
import { randomUUID } from "node:crypto";
export type Role = "pm" | "dev-a" | "dev-b";
export type MessageKind = "status" | "question" | "directive" | "free";
export interface RelayMessage {
id: string;
from: Role;
to: Role;
kind: MessageKind;
body: string;
ts: string;
}
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
export function isRole(s: string): s is Role {
return KNOWN_ROLES.has(s);
}
export class RelayQueue {
private readonly queues = new Map<Role, RelayMessage[]>([
["pm", []],
["dev-a", []],
["dev-b", []],
]);
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
const msg: RelayMessage = {
id: randomUUID(),
from,
to,
kind,
body,
ts: new Date().toISOString(),
};
this.queues.get(to)!.push(msg);
return msg;
}
read(forRole: Role): RelayMessage[] {
const inbox = this.queues.get(forRole)!;
const messages = [...inbox];
inbox.length = 0;
return messages;
}
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
const inbox = this.queues.get(forRole)!;
return {
count: inbox.length,
kinds: inbox.map((m) => m.kind),
};
}
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected output (all 5 passing):
```
▶ RelayQueue
✔ post + read roundtrip returns the message with correct fields
✔ consume-once: second read returns empty
✔ list_pending does not drain inbox
✔ FIFO ordering across multiple senders
✔ isRole rejects unknown strings
▶ RelayQueue (Xms)
tests 5
pass 5
fail 0
```
If any test fails, fix `queue.ts` before proceeding.
- [ ] **Step 5: Commit**
```bash
git add tools/relay/queue.ts tools/relay/queue.test.ts
git commit -m "feat(relay): in-memory queue with consume-once semantics"
```
---
## Task 3: `server.ts`
**Files:**
- Create: `tools/relay/server.ts`
- [ ] **Step 1: Write `server.ts`**
Create `tools/relay/server.ts`:
```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import http from "node:http";
import { RelayQueue, isRole } from "./queue.ts";
const PORT = 7331;
const queue = new RelayQueue();
const mcpServer = new Server(
{ name: "relay", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const TOOLS = [
{
name: "post_message",
description:
"Push a message to a recipient's inbox. Returns the assigned message id.",
inputSchema: {
type: "object" as const,
properties: {
from: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
to: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Recipient role name",
},
kind: {
type: "string",
enum: ["status", "question", "directive", "free"],
description: "Message type matching the coordination protocol",
},
body: {
type: "string",
description: "Message body — freeform markdown, typically the full formatted block",
},
},
required: ["from", "to", "kind", "body"],
},
},
{
name: "read_messages",
description:
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
{
name: "list_pending",
description:
"Return count and kinds of pending messages without consuming them.",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
];
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const a = args as Record<string, string>;
if (name === "post_message") {
if (!isRole(a.from)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
}
if (!isRole(a.to)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
}
const kind = a.kind as "status" | "question" | "directive" | "free";
const msg = queue.post(a.from, a.to, kind, a.body);
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
const ellipsis = a.body.length > 60 ? "..." : "";
process.stdout.write(`[${ts}] ${a.from}${a.to} [${kind}] "${preview}${ellipsis}"\n`);
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
}
if (name === "read_messages") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const messages = queue.read(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
}
if (name === "list_pending") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const result = queue.pending(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
}
return {
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
isError: true,
};
});
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/sse") {
const transport = new SSEServerTransport("/message", res);
transports.set(transport.sessionId, transport);
transport.onclose = () => transports.delete(transport.sessionId);
await mcpServer.connect(transport);
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const sessionId = url.searchParams.get("sessionId") ?? "";
const transport = transports.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "session not found" }));
}
} else {
res.writeHead(404).end("not found");
}
} catch (err) {
console.error("[relay] error:", err);
if (!res.headersSent) res.writeHead(500).end(String(err));
}
});
httpServer.listen(PORT, "127.0.0.1", () => {
console.log(`[relay] server ready on :${PORT}`);
console.log(`[relay] tools: post_message, read_messages, list_pending`);
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
});
```
- [ ] **Step 2: Smoke-test server startup**
In one terminal:
```bash
cd tools/relay && npx tsx server.ts
```
Expected output:
```
[relay] server ready on :7331
[relay] tools: post_message, read_messages, list_pending
[relay] waiting for connections — Ctrl-C to stop
```
In a second terminal, verify the port is listening:
```bash
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
```
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
- [ ] **Step 3: Commit**
```bash
git add tools/relay/server.ts
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
```
---
## Task 4: `start.sh`
**Files:**
- Create: `tools/relay/start.sh`
- [ ] **Step 1: Write `start.sh`**
Create `tools/relay/start.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
PORT=7331
MODE="manual"
for arg in "$@"; do
case "$arg" in
--tmux) MODE="tmux" ;;
--kitty) MODE="kitty" ;;
--manual) MODE="manual" ;;
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
esac
done
# Port check
if lsof -ti:"$PORT" &>/dev/null; then
echo "Error: port $PORT is already in use."
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
exit 1
fi
# Install deps (no-op if node_modules current)
cd "$SCRIPT_DIR"
npm install --silent
# Discover latest coordination prompts for instructions
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
print_manual_instructions() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Open 3 new terminals. In each, start Claude Code and paste"
echo "the content BELOW the '---' line from the corresponding file."
echo ""
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
echo ""
echo "This terminal becomes the relay log. Keep it open."
echo ""
echo "══════════════════════════════════════════════════════════════"
}
launch_tmux() {
SESSION="relay-lift"
tmux new-session -d -s "$SESSION" -n "relay" \
"cd '$SCRIPT_DIR' && npx tsx server.ts"
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
echo "[relay] Paste the kickoff prompt into each Claude window."
echo " Prompts:"
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo ""
tmux attach-session -t "$SESSION"
}
launch_kitty() {
kitty @ launch --new-tab --tab-title "relay" -- \
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
kitty @ launch --new-window --window-title "PM" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-A" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-B" -- \
bash -c "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
echo " Paste the kickoff prompts into each Claude window."
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
}
case "$MODE" in
manual)
print_manual_instructions
exec npx tsx "$SCRIPT_DIR/server.ts"
;;
tmux)
launch_tmux
;;
kitty)
launch_kitty
;;
esac
```
- [ ] **Step 2: Make executable**
```bash
chmod +x tools/relay/start.sh
```
- [ ] **Step 3: Smoke-test `--manual` mode**
```bash
cd /home/alee/Sources/relicario && tools/relay/start.sh
```
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
- [ ] **Step 4: Commit**
```bash
git add tools/relay/start.sh
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
```
---
## Task 5: Claude Code MCP configuration
**Files:**
- Modify: `.claude/settings.json`
- [ ] **Step 1: Read current `.claude/settings.json`**
```bash
cat .claude/settings.json
```
- [ ] **Step 2: Add the relay MCP server entry**
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
```json
{
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}
```
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
- [ ] **Step 3: Commit**
```bash
git add .claude/settings.json
git commit -m "chore(relay): add relay MCP server to project Claude config"
```
---
## Task 6: `docs/superpowers/MULTI-AGENT.md`
**Files:**
- Create: `docs/superpowers/MULTI-AGENT.md`
- [ ] **Step 1: Write the paradigm README**
Create `docs/superpowers/MULTI-AGENT.md`:
```markdown
# Multi-Agent Development Paradigm
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
---
## Overview
| Role | Terminal | Branch | Responsibilities |
|------|----------|--------|-----------------|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
---
## Starting a lift
### Prerequisites
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
- [ ] No uncommitted changes in main that would confuse the devs
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
### Launch sequence
```bash
# 1. Start the relay server (this terminal becomes the relay log)
tools/relay/start.sh # prints copy-paste instructions, then starts server
# Optional: use a multiplexer to auto-open all four terminals
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
```
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
---
## Coordination protocol
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
| Kind | Block header | When used |
|------|-------------|-----------|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
| `free` | (none) | Ad-hoc messages not covered by the above |
A well-formed `status` block:
```
## STATUS UPDATE — DEV-B
Time: 2026-05-02T14:30:00-07:00
Branch: feature/v0.5.0-plan-b-extension-ux
Task: P4 / error-copy map
Status: DONE
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
Tests: green
Notes: No issues. Ready for PM review of P4 before starting B1.
```
---
## Using the relay tools
All three Claude Code sessions have these tools available when the relay server is running:
```
post_message(from, to, kind, body) → { id }
read_messages(for) → RelayMessage[] (drains inbox)
list_pending(for) → { count, kinds } (non-destructive)
```
Typical dev flow per task:
```
1. read_messages(for="dev-b") # check for directives before starting
2. ... do the work ...
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
```
Typical PM flow:
```
1. read_messages(for="pm") # see what devs posted
2. ... review ...
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
```
---
## If the relay server isn't running
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
To restart a crashed server mid-lift:
```bash
tools/relay/start.sh
```
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
---
## Generating kickoff prompts
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be up when each session initializes.
---
## Ending a lift
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
3. PM tags the release (only after explicit user `yes`)
4. Ctrl-C the relay terminal — all in-memory messages are discarded
---
## Roles and boundaries (quick reference)
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
**User must:** authorize all merges and the release tag. Everything else is delegated.
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/MULTI-AGENT.md
git commit -m "docs: add multi-agent development paradigm README"
```
---
## Task 7: Update `multi-agent-kickoff` skill
**Files:**
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
- [ ] **Step 1: Read current templates**
```bash
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
```markdown
<<RELAY_PARAGRAPH>>
```
The generated output for this placeholder (substituted by the skill at generation time) is:
```markdown
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
```
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
```markdown
<<RELAY_PARAGRAPH>>
```
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
```markdown
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"<<DEV_ROLE>>"`
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
```
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
```markdown
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
```
In the "### Per-dev" section, add:
```markdown
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
```
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
```markdown
8. **Print kickoff instructions.** Tell the user exactly what to do:
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
- Open three terminal windows (or panes — their choice of multiplexer)
...rest of existing bullets unchanged...
```
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
```markdown
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
```
- [ ] **Step 6: Commit skill changes**
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
```bash
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Expected: each grep returns at least one match.
---
## Final verification
- [ ] **Run queue tests one more time from repo root**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected: 5 passing, 0 failing.
- [ ] **Start server and verify it binds**
```bash
tools/relay/start.sh &
sleep 1
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
kill %1
```
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
- [ ] **Verify MCP config is present**
```bash
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
```
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
- [ ] **Verify skill placeholders were added**
```bash
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Expected: one match per file.

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

@@ -1,5 +1,7 @@
# Relicario — Design Specification # Relicario — Design Specification
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator. A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
## Overview ## Overview

View File

@@ -0,0 +1,414 @@
# Device Authentication Design
> **Status:** Approved
> **Date:** 2026-05-02
> **Author:** Claude + alee
## Overview
Relicario device authentication provides cryptographic proof of commit authorship and API-managed access control. Each device (CLI instance, browser extension) has its own identity consisting of:
1. **Signing key** (ed25519) — signs git commits
2. **Deploy key** (ed25519) — grants git push access via Gitea API
Device management is fully self-contained within Relicario — no manual SSH key management or server admin panels required.
## Goals
- All commits cryptographically signed by a registered device
- Revocation instantly cuts off both signing authority AND push access
- CLI and extension have full feature parity
- Server-side enforcement via pre-receive hook
- No security theater — every feature actually works
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Vault Repository │
│ .relicario/devices.json ←── public signing keys (ed25519 OpenSSH) │
│ .relicario/revoked.json ←── revoked keys + timestamps │
└─────────────────────────────────────────────────────────────────────┘
▲ ▲
│ sign commits │ verify signatures
│ manage deploy keys (Gitea API) │
│ │
┌───────────┴───────────┐ ┌─────────────┴─────────────┐
│ CLI Device │ │ Gitea Server │
│ ~/.config/relicario │ │ pre-receive hook │
│ /devices/<name>/ │ │ (relicario-server) │
│ signing.key │ └───────────────────────────┘
│ deploy.key │
└───────────────────────┘
┌───────────────────────┐
│ Extension Device │
│ chrome.storage │
│ (encrypted keys) │
│ signing in WASM │
└───────────────────────┘
```
## Key Storage
### CLI Device
```
~/.config/relicario/devices/
├── macbook-cli/
│ ├── signing.key # OpenSSH private key (ed25519) for commit signing
│ ├── signing.pub # OpenSSH public key
│ ├── deploy.key # OpenSSH private key (ed25519) for git push
│ ├── deploy.pub # OpenSSH public key
│ └── gitea_key_id # Gitea's ID for the deploy key (for revocation)
└── current # File containing active device name
```
All private keys stored with mode 0600.
### Extension Device
- Private keys stored in `chrome.storage.local` under `device_keys`
- Encrypted at rest using `HKDF(master_key, "device-storage")`
- WASM holds decrypted keys in memory only while session is active
- Structure:
```json
{
"device_name": "chrome-macos",
"signing_private_key": "<encrypted>",
"signing_public_key": "ssh-ed25519 AAAA...",
"deploy_private_key": "<encrypted>",
"deploy_public_key": "ssh-ed25519 AAAA...",
"gitea_key_id": 42
}
```
### Vault Files
**`devices.json`:**
```json
[
{
"name": "macbook-cli",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
"added_at": 1714600000,
"added_by": "macbook-cli"
}
]
```
**`revoked.json`:**
```json
[
{
"name": "stolen-laptop",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
"revoked_at": 1714700000,
"revoked_by": "macbook-cli"
}
]
```
## Vault Configuration
Stored encrypted in vault settings:
```json
{
"git_provider": "gitea",
"git_api_url": "https://git.adlee.work/api/v1",
"git_api_token": "...",
"repo_owner": "alee",
"repo_name": "relicario-vault"
}
```
Required Gitea token scopes: `repo`, `admin:repo_key`
## CLI Flows
### Device Add
```bash
relicario device add --name "macbook-cli"
```
1. Generate ed25519 signing keypair (OpenSSH format)
2. Generate ed25519 deploy keypair (OpenSSH format)
3. Call Gitea API: `POST /repos/{owner}/{repo}/keys`
```json
{
"title": "relicario-macbook-cli",
"key": "ssh-ed25519 AAAA...",
"read_only": false
}
```
4. Store keys to `~/.config/relicario/devices/macbook-cli/`
5. Write device name to `~/.config/relicario/devices/current`
6. Append public signing key to `.relicario/devices.json`
7. Configure local git repo:
```
git config user.signingkey ~/.config/relicario/devices/macbook-cli/signing.key
git config gpg.format ssh
git config commit.gpgsign true
git config core.sshCommand "ssh -i ~/.config/relicario/devices/macbook-cli/deploy.key"
```
8. Commit: `device: add macbook-cli`
9. Push
### Device Revoke
```bash
relicario device revoke stolen-laptop
```
1. Read `devices.json`, find entry for `stolen-laptop`
2. Call Gitea API: `DELETE /repos/{owner}/{repo}/keys/{key_id}`
3. Remove from `devices.json`
4. Append to `revoked.json`:
```json
{
"name": "stolen-laptop",
"public_key": "ssh-ed25519 AAAA...",
"revoked_at": 1714700000,
"revoked_by": "macbook-cli"
}
```
5. Commit: `device: revoke stolen-laptop`
6. Push immediately
### Device List
```bash
relicario device list
DEVICE ADDED STATUS
macbook-cli 2024-05-01 active (current)
chrome-macos 2024-05-02 active
stolen-laptop 2024-04-15 revoked 2024-05-01
```
### Verify Commit
```bash
relicario verify [commit-ish]
```
Checks signature against `devices.json`, reports device name and status.
### Sync (Enhanced)
```bash
relicario sync
```
1. Verify HEAD is signed by current device
2. Pull with rebase
3. Warn on unsigned or unknown-signed incoming commits
4. Push
## Extension/WASM Flows
### WASM API
```rust
#[wasm_bindgen]
pub fn register_device(session: &SessionHandle, name: &str) -> Result<JsValue, JsError>
// Generates both keypairs, stores encrypted, returns public keys only
// Returns: { signing_public_key: "ssh-ed25519...", deploy_public_key: "ssh-ed25519..." }
#[wasm_bindgen]
pub fn sign_for_git(session: &SessionHandle, data: &[u8]) -> Result<JsValue, JsError>
// Loads encrypted signing key, decrypts, signs, returns signature
// Returns: { signature: "base64..." }
#[wasm_bindgen]
pub fn get_device_info(session: &SessionHandle) -> Result<JsValue, JsError>
// Returns: { name, signing_public_key, deploy_public_key } or null
#[wasm_bindgen]
pub fn clear_device(session: &SessionHandle) -> Result<(), JsError>
// Removes device keys from storage (for re-registration)
```
**Critical constraint:** Private key bytes never cross WASM boundary to JS. Generated in WASM, encrypted in WASM, decrypted in WASM, used in WASM.
### Extension Registration Flow
1. User clicks "Register this device" in settings
2. Prompt for device name (default: "Chrome on macOS")
3. Call WASM `register_device(name)` → returns public keys
4. Service worker calls Gitea API to register deploy key
5. Service worker updates `devices.json`, commits, pushes
6. Device is now registered
### Extension Commit Signing
When extension modifies vault:
1. Service worker prepares commit
2. Calls WASM `sign_for_git(commit_data)` → returns signature
3. Creates signed commit using git SSH signature format
4. Pushes using deploy key
## Server-Side Verification
### Hook Distribution
**Option B — CLI generates:**
```bash
relicario server-hook generate > pre-receive
chmod +x pre-receive
# Copy to Gitea hooks directory
```
**Option C — Standalone binary:**
```bash
cargo install relicario-server
# Or download prebuilt binary
```
### Pre-Receive Hook
```bash
#!/bin/bash
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
```
### Verification Logic
`relicario-server verify-commit <commit>`:
1. Extract `devices.json` and `revoked.json` from repo at commit
2. Get commit signature via `git verify-commit --raw`
3. Parse signature, extract signing public key
4. Check key against `devices.json`:
- Not found → reject "signed by unregistered device"
5. Check key against `revoked.json`:
- Found AND commit timestamp ≥ revoked_at → reject "signed by revoked device"
- Found AND commit timestamp < revoked_at → accept (historical)
6. Accept
### Gitea Installation
```bash
# Per-repo hook
cp pre-receive /path/to/gitea-data/git/repositories/alee/vault.git/hooks/pre-receive
# Or via Gitea admin UI
# Settings → Git Hooks → pre-receive → paste script
```
## Error Handling
### Device Registration
| Error | CLI | Extension |
|-------|-----|-----------|
| Gitea API unreachable | Fail: "cannot reach git server" | Toast + retry |
| API token invalid | Fail: "API token rejected" | Prompt re-enter in settings |
| Deploy key name collision | Append `-2`, `-3` or fail | Same |
### Signing
| Error | Behavior |
|-------|----------|
| No device registered | Block: "run `relicario device add`" |
| Private key not found | Prompt re-registration |
| Key decryption fails | Session expired, prompt unlock |
### Server Verification
| Error | Hook Response |
|-------|---------------|
| Unsigned commit | Reject: "all commits must be signed" |
| Unknown signing key | Reject: "signed by unregistered device" |
| Revoked key (post-revocation) | Reject: "signed by revoked device 'X'" |
### Revocation Edge Cases
| Scenario | Behavior |
|----------|----------|
| Revoke current device | Require `--confirm`, warn about access loss |
| Revoke last device | Error: "cannot revoke last device" |
| Gitea API fails during revoke | Revoke signing key, warn about manual deploy key cleanup |
## Testing Strategy
### Unit Tests (relicario-core)
- Key generation and OpenSSH format serialization
- Sign/verify round-trip
- `devices.json` / `revoked.json` serialization
### Integration Tests (relicario-cli)
- `device add` creates keys and configures git
- `device revoke` updates both JSON files
- Commits are signed after device add
- `verify` accepts/rejects appropriately
### Integration Tests (Gitea API)
- Mock Gitea API for deploy key management
- Graceful failure on API errors
### WASM Tests
- `register_device` returns only public keys
- `sign_for_git` never exposes private key
- Round-trip signing works
### E2E Tests (Server Hook)
- Unsigned commits rejected
- Valid signatures accepted
- Revoked device signatures rejected (post-revocation)
- Historical commits by later-revoked devices accepted
## Bootstrapping
**Problem:** The first device can't sign its own registration commit — there's no device yet.
**Solution:** Bootstrap exception in the pre-receive hook:
1. `relicario init` creates vault with empty `devices.json` (unsigned commit allowed)
2. First `device add` registers itself (this commit is also unsigned — no prior device)
3. Hook logic: if `devices.json` is empty in the parent commit, allow unsigned
4. All subsequent commits must be signed
**Extension bootstrap:** If connecting to an existing vault that has no devices:
1. Extension detects empty `devices.json`
2. Prompts to register as first device
3. Same unsigned-commit exception applies
**Security implication:** Anyone with push access can add the first device. This is acceptable because:
- Push access already requires git credentials
- The hook isn't installed yet anyway on a fresh repo
- Once first device is registered, all subsequent changes require signing
## Security Properties
1. **Commit authorship is cryptographically proven** — ed25519 signatures
2. **Revocation is instant and complete** — deploy key deletion via API
3. **Private keys never leave their device** — WASM constraint enforced
4. **History is append-only** — revocation doesn't invalidate past commits
5. **Server enforces, client assists** — hook is authoritative, client checks are UX
6. **Bootstrap is explicit** — first device registration requires push access, then locked down
## Future Considerations
- Hosted Relicario service with per-user isolated git backends
- Support for other git providers (GitHub, GitLab) via their deploy key APIs
- Hardware key support (YubiKey) for signing key storage

View File

@@ -0,0 +1,338 @@
# Phase 2B: Polish Foundation + Form Layout
**Date:** 2026-05-02
**Status:** Spec, awaiting review
**Surface:** Browser extension — popup, fullscreen vault, setup wizard
**Parent spec:** `docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md`
## Goal
Bring the extension up to a "professional, contained" feel — like opening 1Password or another polished password manager — without losing the terminal-monospace soul. Three surfaces (login popup, setup wizard, fullscreen vault) all get the same polish vocabulary applied. Phase 2B also lands the two-column login form layout from the parent spec.
## What changed from the original Phase 2B scope
The original Phase 2B was scoped to form layout only. After visual review, we expanded scope to include:
- A **patina** palette shift — gold accent dialed from bright `#d2ab43` toward weathered `#a88a4a`/`#cdb47a`/`#5a3f12`. Red theca dialed from saturated `#9a1a1a` toward brick `#7d2622`.
- **Logo update** — same composition, patina palette, translucent gradient gem.
- **Polish vocabulary** — backdrop with subtle radial glow + 18px grid texture, glass cards (translucent panels with backdrop-blur), refined typography lockup, primary/secondary button hierarchy.
- **Arrow glyph** — `▸` (U+25B8) for "next" buttons, matching the `▾`/`▸` disclosure glyphs already in use.
These items now ship together so the form layout lands inside an already-polished surface, rather than as a layout change inside flat CSS.
## Non-goals
- Three-pane shell, keyboard nav, command palette — deferred to Phase 3.
- New affordances — Phase 2A already shipped the 8 smart inputs.
- Light theme — single dark theme stays.
- Mobile/narrow layouts under 720px — popup handles narrow.
- Animated transitions / motion — focus state is the only transition.
- Item types other than `login` getting a two-column treatment.
## Visual language
The polish vocabulary lives in `extension/src/popup/styles.css` and `extension/src/vault/vault.css`. Both files share token definitions and class names where possible.
### Palette (patina)
```css
:root {
/* Patina gold — replaces the bright amber */
--gold-base: #a88a4a; /* base, less yellow / more bronze */
--gold-mid: #cdb47a; /* duller mid-highlight */
--gold-shadow: #5a3f12; /* deeper bronze shadow */
--gold-text: #c9a868; /* legible on dark, brand text */
--gold-soft: rgba(184,149,86,0.14); /* hover/active fill */
--gold-ring: rgba(184,149,86,0.18); /* focus ring */
--gold-stroke: #b89556; /* default border on emphasized elements */
/* Surface — slightly deeper than current bg */
--bg-base: #0a0e14; /* page (was #0d1117) */
--bg-pane: #11161e; /* slightly elevated surface */
--bg-card: rgba(22, 27, 34, 0.55); /* glass card fill */
--bg-input: #0a0e14; /* matches base for sunken feel */
/* Borders */
--border-soft: rgba(255,255,255,0.05); /* card edges */
--border-mid: #262d36; /* input borders */
--border-warm: #2a3140; /* slightly warmer for vault.css */
/* Text */
--text: #c9d1d9;
--text-muted: #8b949e;
--text-dim: #6b7888;
}
```
The bright accent token `--accent: #d2ab43` is renamed to `--gold-base: #a88a4a`. Aliases keep existing component code working during the migration (`--accent: var(--gold-base)`).
### Backdrop
A reusable backdrop applied to popup body, setup wizard body, and the vault shell:
```css
.surface-backdrop {
position: relative;
background:
radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184,149,86,0.05), transparent 65%),
linear-gradient(180deg, #11161e 0%, #0a0e14 100%);
}
.surface-backdrop::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.012) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px);
background-size: 18px 18px;
pointer-events: none;
}
.surface-backdrop > * { position: relative; z-index: 1; }
```
The radial top-glow opacity is intentionally low (`0.05`) so it doesn't wash out on cheaper monitors. The grid texture is barely visible (`0.012` white) — adds a sense of "place" without becoming busy.
### Glass card
Used for the unlock card, setup step card, mode-picker cards, and form section cards (Identity / Credentials):
```css
.glass {
background: rgba(22, 27, 34, 0.55);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
box-shadow:
0 1px 0 rgba(255,255,255,0.03) inset,
0 6px 18px rgba(0,0,0,0.35);
}
```
Browsers without `backdrop-filter` support fall back gracefully — the card stays semi-translucent over the backdrop without the blur.
### Buttons
Two clear tiers:
```css
.btn-primary {
background: var(--gold-base);
color: var(--bg-base);
border: none;
padding: 9px 14px;
font-size: 12px;
font-weight: 600;
border-radius: 6px;
letter-spacing: 0.3px;
}
.btn-primary:hover { background: #c9a868; }
.btn-secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.06);
color: var(--text-muted);
padding: 6px 12px;
font-size: 11px;
border-radius: 5px;
}
```
Existing `.btn` class keeps existing styling for backwards compatibility; new `.btn-primary` / `.btn-secondary` are used in updated views.
### Typography lockup
Logo + brand + tagline group with tighter spacing on login and setup:
- Logo mark: 40-44px square, 9-10px corner radius, inner highlight only (no outer glow).
- Brand text: weight 600, color `var(--gold-text)`, letter-spacing 0.5px.
- Tagline: 11px, `var(--text-dim)`, letter-spacing 0.3px.
### Inputs
```css
.input {
background: var(--bg-input);
border: 1px solid var(--border-mid);
color: var(--text);
padding: 9px 10px;
border-radius: 6px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus {
outline: none;
border-color: var(--gold-stroke);
box-shadow: 0 0 0 2px var(--gold-ring);
}
```
### Arrow glyph
The `▸` (U+25B8, small right triangle) replaces ASCII `→` in "next" buttons. Reuses the existing disclosure-glyph vocabulary already used in `▾ custom sections` / `▸ attachments`.
## Logo update
`extension/icons/relicario-logo.svg` and `extension/icons/relicario-logo-16.svg` updated:
- Gold gradient stops shifted: `#d2ab43 → #f5d97a → #7c5719` becomes `#a88a4a → #cdb47a → #5a3f12`.
- Red theca radial: `#9a1a1a → #3a0a0a` becomes `#7d2622 → #2c0d0a`.
- Highlight gradient: `#fde9a8 → #d2ab43` becomes `#dac8a0 → #a88a4a`.
- Solid gold tones (`#7c5719`, `#fff3cf`, `#8a5e1c`) remapped to patina equivalents.
- Center asterisk gem now translucent: facets use vertical gradients (`gemFacetLight` / `gemFacetDark`) that fade to transparent at the tip; gem core uses a radial glass gradient (`gemCore`); two refraction highlights replace the single white-yellow dot.
The composition (pedestal, theca, gem, hinge collar, fleur-de-lis) is unchanged.
## Surface-by-surface changes
### Login popup (`extension/src/popup/`)
`unlock.ts` view:
```
┌──────────────────────────────────┐
│ [logo] │
│ Relicario │
│ two-factor vault │
│ │
│ ┌─[ glass card ]──────────────┐ │
│ │ UNLOCK │ │
│ │ [passphrase input ] │ │
│ │ [ unlock vault ] │ │ ← btn-primary, full width
│ └──────────────────────────────┘ │
│ │
│ [open vault] [settings] │ ← btn-secondary, demoted
└──────────────────────────────────┘
```
- Body gets `.surface-backdrop`.
- Logo lockup grouped (logo / brand / tagline) with tighter spacing (8-12px between).
- Form moves into `.glass` card with `UNLOCK` label inside.
- Primary action is a real button ("unlock vault") — replaces the "press Enter to submit" implicit flow.
- Open-vault and settings demoted to secondary buttons below the card.
### Setup wizard (`extension/src/setup/`)
- Body gets `.surface-backdrop`.
- Header lockup at top (logo + "Relicario vault setup").
- Progress dots get a tiny shadow on the current step (`box-shadow: 0 0 4px rgba(184,149,86,0.4)`).
- Each `wizard-step` becomes a `.glass` card.
- Mode-picker cards become smaller `.glass` cards with patina active state.
- All "next" buttons use `▸` glyph.
### Fullscreen vault (`extension/src/vault/`)
- Body gets `.surface-backdrop`.
- Form section panels (Identity, Credentials) are `.glass` cards.
- Save bar matches glass treatment with translucent fill + backdrop-blur.
- Form layout switches to two-column for login (see below).
## Form layout (login, fullscreen only)
The original Phase 2B scope:
```
┌────────────────────────────────────────────────────────────┐
│ edit login ⌘+S to save │
│ unsaved · esc to cancel │
├──────────────────────────┬─────────────────────────────────┤
│ [ glass: IDENTITY ] │ [ glass: CREDENTIALS ] │
│ title [required] │ username │
│ url + ⤓ │ password ⊙ ↻ │
│ group (autocomplete) │ strength: ████░ │
│ │ totp secret ◫ │
│ │ live: 492 837 · 23s │
├──────────────────────────┴─────────────────────────────────┤
│ NOTES │
│ ▾ custom sections ▸ attachments │
├────────────────────────────────────────────────────────────┤
│ STICKY SAVE BAR [cancel] [save] │
└────────────────────────────────────────────────────────────┘
```
### Layout rules
- Form pane content: `max-width: 960px`, `margin: 0 auto`.
- Two-column wrapper: `display: grid; grid-template-columns: 1fr 1fr; gap: 24px;`.
- Below 720px viewport: `grid-template-columns: 1fr` (single column stack).
- Notes / custom sections / attachments live in a sibling block below the grid, full-width.
### Column assignment (login)
**Left column — IDENTITY:**
- title (required pill)
- url + `⤓` fill-from-tab button + hostname chip below
- group input + datalist autocomplete
**Right column — CREDENTIALS:**
- username
- password + `⊙` reveal + `↻` generate; strength bar below the input
- totp secret + `◫` QR button; live preview below the input
**Full-width below grid:**
- notes (with `≡` mono toggle)
- custom sections / fields disclosure
- attachments disclosure
### Sticky save bar
- `position: sticky; bottom: 0;` inside the form pane scroll container.
- Translucent fill matching the glass vocabulary, with a 24px gradient fade above (content scrolls under).
- Right-aligned `[cancel] [save]` buttons.
- Save button reflects validity state — disabled when required fields are empty.
### Header treatment
- Title (left): `new login` / `edit login`, weight 500, size 18px.
- Subtitle (left, below title): `unsaved · esc to cancel` (dirty) or `no changes` (pristine), `var(--text-muted)`, size 12px.
- Hint (right): `⌘+S to save` (visual only — actual save shortcut arrives in Phase 3 keymap), `var(--text-dim)`, size 12px. Renders `Ctrl+S` on non-mac.
- Popout-to-tab `⤴` removed from fullscreen forms (already done in Phase 1).
### Other item types
Single-column stays for `secure_note`, `identity`, `card`, `key`, `totp`, `document`. They still get the new glass-card treatment around the form section, sticky save bar, and header treatment — only the column grid is login-specific.
## Files touched
| File | Change |
|------|--------|
| `extension/icons/relicario-logo.svg` | Patina palette + gradient glass gem |
| `extension/icons/relicario-logo-16.svg` | Patina palette (toolbar size) |
| `extension/src/popup/styles.css` | Patina tokens, `.surface-backdrop`, `.glass`, `.btn-primary/secondary` |
| `extension/src/popup/components/unlock.ts` | Logo lockup, glass card, primary unlock button |
| `extension/src/popup/components/types/login.ts` | Add `surface: 'popup' \| 'fullscreen'` param; column wrapping when fullscreen |
| `extension/src/setup/setup.ts` | `.surface-backdrop`, glass step cards, glass mode-picker cards, `▸` arrows |
| `extension/src/setup/setup.html` | Body wrapper class |
| `extension/src/vault/vault.css` | Patina tokens, glass form sections, form-grid, sticky save bar, header treatment |
| `extension/src/vault/vault.ts` | Header subtitle dirty-state subscriber, surface flag passed to login renderer |
| `extension/src/vault/components/*.ts` | Glass class on form panels |
The login renderer needs to know which surface it's rendering into (popup vs fullscreen). Add an optional `surface: 'popup' | 'fullscreen'` parameter to `renderForm()`. Default to `popup` to preserve existing behavior.
## Testing
Per-area tests using existing `vitest` + `happy-dom` setup:
1. **Palette migration test:** computed style on `.btn-primary` resolves to `#a88a4a`.
2. **Layout test:** mount login form with `surface: 'fullscreen'`, assert grid layout, Identity / Credentials columns contain expected fields.
3. **Stack-down test:** simulate viewport ≤720px, assert single-column.
4. **Dirty subtitle test:** mount form, simulate input, assert subtitle text changes.
5. **Sticky bar test:** assert save bar exists, position style, save button validity.
6. **Glass class application:** unlock card, setup step card, form panels all get `.glass` class.
7. **Arrow glyph test:** all "next" buttons render `▸` (no `→`).
8. **Other-type non-regression:** mount `secure_note`, assert single-column layout.
9. **Logo regression:** snapshot test on `relicario-logo.svg` defs/colors.
Manual QA pass per surface with the rebuilt extension loaded in Chrome and Firefox.
## CLI parity
This phase is purely visual / layout-shaped. No CLI counterpart. The CLI already accepts all login fields as flags (`--title`, `--username`, `--password`, etc.).
## Out of scope / deferred
- Two-column layout for non-login types.
- User-resizable column widths.
- Animated transitions on subtitle text change (snap is fine).
- Functional ⌘+S keyboard shortcut — arrives with Phase 3 keymap. The hint is a visual label until then.
- Diff view / form-level "you changed N fields" indicator.
- Light theme.

View File

@@ -0,0 +1,218 @@
# Relay Server Design
**Date:** 2026-05-02
**Status:** Approved
**Scope:** Dev tooling — not shipped in any product artifact
---
## Problem
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
## Goal
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
---
## Repository layout
```
tools/relay/
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
├── tsconfig.json
├── server.ts # MCP SSE server entry point (~150 lines)
├── queue.ts # in-memory queue logic (~50 lines)
├── queue.test.ts # Node built-in test runner
└── start.sh # launcher script
```
Added to root `.gitignore`: `tools/relay/node_modules/`, `tools/relay/dist/`.
---
## Tech stack
- **Runtime:** Node.js (v25, already installed at `/usr/bin/node`)
- **Package manager:** npm (bun has known compat gaps with the MCP SDK's SSE transport)
- **Dependencies:** `@modelcontextprotocol/sdk`, `tsx` (devDependency, runs TypeScript directly — no compile step)
- **Transport:** SSE (`SSEServerTransport` from the SDK handles the HTTP layer — no Express or Hono needed)
- **Port:** `7331` (hardcoded; easy to remember, unlikely to collide)
---
## Queue model
Three named inboxes: `pm`, `dev-a`, `dev-b`. Each is a FIFO array in memory.
Message shape:
```ts
interface RelayMessage {
id: string; // uuid v4
from: string; // sender role name
to: string; // recipient role name
kind: "status" | "question" | "directive" | "free";
body: string; // freeform, typically the existing markdown block format
ts: string; // ISO 8601
}
```
`kind` maps to the existing coordination protocol:
| kind | existing block |
|-------------|-----------------------------|
| `status` | `## STATUS UPDATE — DEV-*` |
| `question` | `## QUESTION TO PM — DEV-*` |
| `directive` | `## DIRECTIVE TO DEV-*` |
| `free` | ad-hoc / unstructured |
Messages are **consume-once**: `read_messages` drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
---
## MCP tool surface
All three tools are exposed to every connected session.
### `post_message`
```
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
```
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if `to` or `from` is not a known role. Agents declare their own identity via `from` — the kickoff prompt tells each agent its role name.
### `read_messages`
```
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
```
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
### `list_pending`
```
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
```
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a `read_messages` call.
---
## Server terminal output
Every `post_message` call prints a one-liner to stdout in the dedicated relay terminal:
```
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
```
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
---
## Launcher script (`start.sh`)
`start.sh` accepts one optional flag:
| Flag | Behavior |
|------------|----------|
| *(default)*| `--manual` mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
| `--tmux` | Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
| `--kitty` | Same layout using kitty's `launch --new-tab` / `--new-window` |
Execution order (all modes):
1. `cd tools/relay && npm install --silent` (no-op if `node_modules` is current)
2. Print the session snippet (copy-paste blocks or multiplexer launch)
3. Foreground `npx tsx server.ts` — the terminal that ran `start.sh` becomes the relay terminal; no compile step needed
Port-already-in-use check before step 3: if `:7331` is bound, print `relay already running? kill it with: kill $(lsof -ti:7331)` and exit 1.
---
## Claude Code configuration
Add to project `.claude/settings.json`:
```json
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
}
```
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
---
## Kickoff prompt changes
One paragraph added near the top of each coordination prompt (`v0.5.0-pm-prompt.md`, `v0.5.0-dev-a-prompt.md`, `v0.5.0-dev-b-prompt.md` as template):
> **Relay server:** A message-bus MCP server is running. You have three tools: `post_message(to, kind, body)`, `read_messages(for)`, `list_pending(for)`. Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task call `read_messages(for="<your-role>")`. After emitting any status, question, or directive block, call `post_message` with `kind` set to the block type and `body` set to the formatted block.
The `multi-agent-kickoff` skill is updated to:
- Remind the user to run `tools/relay/start.sh` before opening the three sessions
- Inject the relay paragraph automatically into every generated kickoff prompt
---
## Error handling
| Scenario | Behavior |
|----------|----------|
| Unknown `to` in `post_message` | MCP error returned; message not queued |
| Server crash / restart | In-flight messages lost; agent re-sends |
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
No authentication. This is localhost-only, single-machine, dev-tool use.
---
## Testing
`queue.test.ts` using Node's built-in `node:test` runner. No extra test dep.
Coverage:
- `post_message` + `read_messages` roundtrip (single and multiple messages)
- Consume-once: second `read_messages` on same inbox returns empty
- `list_pending` does not drain inbox
- FIFO ordering across multiple senders to the same inbox
- Unknown recipient returns an error
No integration test against the MCP SSE transport — that is the SDK's responsibility.
---
## Top-level README (`docs/superpowers/MULTI-AGENT.md`)
A durable reference document covering the whole development paradigm — not the relay server specifically, but the entire three-terminal workflow that the relay server enables. Lives in `docs/superpowers/` alongside the specs and plans it describes.
**Contents:**
1. **Overview** — the PM/Dev-A/Dev-B pattern: why three terminals, what each role owns, what the user's job is (authorize merges, resolve escalations)
2. **Starting a lift** — prerequisites checklist, then: `tools/relay/start.sh` → three sessions → paste kickoff prompts
3. **Coordination protocol reference** — the four message kinds (`status`, `question`, `directive`, `free`), when each is used, what a well-formed body looks like
4. **Using the relay tools**`post_message`, `read_messages`, `list_pending` with one-liner examples
5. **If the relay server isn't running** — fallback to manual copy-paste; the coordination protocol still works, just with the user as bus
6. **Generating kickoff prompts** — point to the `multi-agent-kickoff` skill; note that the skill injects the relay paragraph automatically
7. **Ending a lift** — PM emits MERGE-APPROVED, devs push branches, user authorizes merges, Ctrl-C the relay terminal
This README is written for future-you opening the repo six months from now, not for the current lift.
---
## What this is not
- Not a product feature — never bundled with the extension or CLI
- Not persistent — no SQLite, no file queue, in-memory only
- Not authenticated — localhost dev tool, no threat model
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration

View File

@@ -0,0 +1,340 @@
# v0.5.0 — Polish + Harden — Design
Date: 2026-05-02
Status: Draft
## Overview
v0.5.0 is a "polish + harden" bundle: a security-vulnerability fix, two
hardening follow-ups, two confirmed bugs, and four UX improvements.
No new functional features. Functional work — LastPass import, recovery
QR, Plan 1C-γ, Fullscreen Phases 3/4 — proceeds on parallel plans.
The anchor item is a **HIGH-severity authentication bypass** in
`relicario-server`'s pre-receive hook (S1): both the registered-device
check and the revocation check are unimplemented. Until this lands, the
device-auth system is a no-op.
## Goals
1. Restore device-auth integrity: only registered, non-revoked signing
keys can push to the vault repo.
2. Close two minor hardening gaps surfaced during the security review.
3. Fix two user-visible bugs (strength-meter desync after generate,
raw error codes in the fullscreen tab).
4. Deliver four UX improvements that are polish-shaped, not feature-shaped.
5. Tag v0.5.0 with no known security vulnerabilities or visible-error-code
leaks.
## Scope Map
| Bucket | Item | Plan |
|---|---|---|
| Security | S1: Pre-receive hook fix | A |
| Security | S2: Tar archive path-traversal hardening | A |
| Security | S3: `RELICARIO_*` env-var audit | A |
| Cleanup | C1: Stale feature branch prune | A |
| Bugs | B1: Strength meter desync after regenerate | B |
| Bugs | B2: `vault_locked` raw error code in fullscreen tab | B (subsumed by P4) |
| UX | P1: Password coloring | B |
| UX | P2: Setup-wizard completion → fullscreen vault tab | B |
| UX | P3: Form-layout 2-col → full-width transition | B |
| UX | P4: Error-message audit (snake_case codes → friendly copy) | B |
Two plans split by language/blast-radius:
- **Plan A** = Rust + docs (server, CLI, env-var audit, branch cleanup)
- **Plan B** = Extension UX (TypeScript + CSS)
The plans share no files and can ship independently.
---
## Plan A — Security + Cleanup
### S1. Pre-Receive Hook Fix (anchor)
**Problem.** `crates/relicario-server/src/main.rs:36-81`: `verify_commit`
loads `devices` and `revoked` from the repo at the commit, then drops
both on the floor. It runs `git verify-commit --raw` and accepts on
`GOODSIG` / `Good signature` regardless of which key produced the
signature. This means:
- An attacker who never registered a device can push commits signed
with any GPG key that the server's keyring/allowed-signers happens
to trust (or none at all if the server has no SSH allowed-signers
configured).
- A revoked device's signing key continues to authenticate after
`relicario device revoke` is run — the `revoked.json` write is
cosmetic.
**Fix.** Implement the verification logic per the design spec
(`docs/superpowers/specs/2026-05-02-device-authentication-design.md:284-300`):
1. Build a temporary allowed-signers file from `devices.json` entries
loaded at the commit. Pass it to `git verify-commit` via
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile
GIT_CONFIG_VALUE_0=<tmpfile>` so we don't mutate global git config.
2. Parse the signing-key fingerprint out of `git verify-commit --raw`
output (the fingerprint appears on the `Good "git" signature for …
with … key SHA256:…` line for SSH).
3. Reject if the fingerprint is not in `devices.json` at the commit.
4. Reject if the fingerprint is in `revoked.json` AND
`commit_timestamp >= revoked_at`. The historical-commit case
(timestamp < `revoked_at`) is allowed so old commits survive
revocation.
5. Use the commit's **committer date** (`GIT_COMMITTER_DATE`,
accessible via `git show -s --format=%ct`) — *not* the current wall
clock and not the author date — as `commit_timestamp`. Committer
date is when the signature was applied; author date is when work
was originally written and could be from before revocation even
for a malicious replay.
**Acceptance.**
- An integration test that registers a device, revokes it, signs a
commit with the revoked key dated AFTER `revoked_at`, and confirms
the hook exits non-zero.
- An integration test that signs a commit with a key that was never
registered and confirms the hook exits non-zero.
- An integration test that signs a commit with a registered, non-revoked
key and confirms the hook exits zero.
- An integration test that signs a commit with a revoked key dated
BEFORE `revoked_at` (historical case) and confirms the hook exits zero.
- The dead `let _ = &revoked;` line is gone.
### S2. Tar Archive Path-Traversal Hardening
**Problem.** `crates/relicario-cli/src/main.rs:1722` unpacks the
bundled `git_archive` from a `.relbak` via `tar::Archive::unpack`,
trusting the `tar` crate's defaults. A malicious `.relbak` could
contain entries with `..` components or absolute paths.
**Fix.** Iterate entries explicitly:
- Reject any entry whose path contains a `..` component, an absolute
prefix, or a Windows drive letter.
- Reject symlinks and hardlinks (we never write either inside `.git/`
on restore).
- Cap total uncompressed size at 100× the compressed `git_archive`
size or 1 GiB, whichever is lower (defends against tar bombs).
- Write each entry under `target.join(".git")` only after the path
resolves *inside* that directory.
**Acceptance.**
- Unit test with a hand-crafted tar containing `../../etc/passwd`
restore bails with "path traversal blocked".
- Unit test with a symlink entry — restore bails.
- Unit test with a 1 TiB sparse entry — restore bails on the size cap.
- Existing valid-restore test still passes.
### S3. `RELICARIO_*` Env-Var Audit
**Problem.** Three env vars are not gated by `cfg(debug_assertions)`:
- `RELICARIO_IMAGE` (`crates/relicario-cli/src/session.rs:125`) — reference-image override path.
- `RELICARIO_NO_GROUPS_CACHE` (`crates/relicario-cli/src/helpers.rs:103`) — disables the groups cache.
- `RELICARIO_GITEA_URL` (`crates/relicario-cli/src/main.rs:2298`) — Gitea base URL fallback.
Spot-check confirmed these look intentional, but they're undocumented
and `RELICARIO_NO_GROUPS_CACHE` is more of a dev escape hatch than
production config.
**Fix.**
- Document each env var in `docs/SECURITY.md` and the relevant
`--help` text.
- Move `RELICARIO_NO_GROUPS_CACHE` under `cfg(debug_assertions)`
it's a dev tool, not a user knob.
- Leave `RELICARIO_IMAGE` and `RELICARIO_GITEA_URL` as user-facing
config; audit each call site to confirm no untrusted-input-flows-into-path
cases.
**Acceptance.**
- `docs/SECURITY.md` lists every `RELICARIO_*` env var with purpose
and trust assumption.
- `RELICARIO_NO_GROUPS_CACHE` is no-op in `cargo build --release`.
### C1. Stale Feature Branch Prune
**Problem.** Six local branches with no merged history beyond what's
already in main: `feature/fullscreen-ux-phase-2a`,
`feature/typed-items-1a-rust-core`, `feature/typed-items-1c-alpha`,
`feature/typed-items-1c-beta1`, `feature/typed-items-1c-beta2`.
**Fix.** Verify each is fully merged into main (`git branch --merged
main`), then delete locally only — no remote branch operations. Plan
execution will surface the list and prompt the user before running
`git branch -D` per branch (per project rule on git-destructive ops).
**Acceptance.** `git branch` shows only `main` and active feature
branches.
---
## Plan B — Extension UX
### B1. Strength Meter Desync After Regenerate
**Problem.** Confirmed in screenshot from 2026-05-02: clicking the
regenerate button on a login form's password field rerolls a
20-character mixed-class password (e.g., `sCMtTJkF%GN^mF#-N6D%`) but
the strength meter still reports `~10^1 guesses — trivially crackable`.
The rated string is stale — likely whatever was in the field before
the reroll, or empty.
The meter listens to `input` events on the password field
(`extension/src/shared/form-affordances/password-tools.ts:65`). The
regenerate handler sets `input.value = newPassword` programmatically,
which **does not fire** `input` events in standard DOM behavior.
**Fix.** In the regenerate handler (search for the orange-spinner
button click), after assigning `input.value`, dispatch an
`InputEvent('input', { bubbles: true })`. Confirm by re-rating
inside the handler if needed.
**Acceptance.**
- Vitest: simulate regenerate click, assert that `input` event is
dispatched and meter calls `scheduleRate` with the new value.
- Manual: regenerate a password on the login form, confirm meter
jumps to "strong" with appropriate `guesses_log10`.
### P4. Error-Message Audit (subsumes B2)
**Problem.** Snake_case error codes leak straight into the fullscreen
tab and other surfaces:
- `vault_locked` shown as a red string in the new-login form
(screenshot 2026-05-02).
- ~20 call sites in `extension/src/service-worker/router/` return
`{ ok: false, error: 'vault_locked' }` and similar.
The popup has a one-off mapping at `extension/src/popup/popup.ts:147`
(`/vault_locked/i.test(err)` → unlock prompt), but the fullscreen tab
has no equivalent.
**Fix.**
1. Build a single `ERROR_COPY` map keyed by error code, returning
`{ title: string; body: string; cta?: { label: string; action: () => void } }`.
Place at `extension/src/shared/error-copy.ts`.
2. Audit call sites in `extension/src/service-worker/router/` for all
error codes; ensure each has a mapping.
3. Replace the regex in `popup.ts:147` and the raw-error rendering in
the fullscreen tab with `lookupErrorCopy(code)`.
4. For `vault_locked` specifically: the CTA is "Unlock vault" → opens
the popup unlock view (or in the fullscreen tab, the inline unlock
form).
**Acceptance.**
- No raw `snake_case` strings render in any UI surface for any error
return path. Verified by grep + manual trigger of each error code.
- `vault_locked` in the fullscreen tab shows friendly copy + an
"Unlock vault" button that works.
- Vitest: enumerate every distinct `error: '...'` string literal
found in `extension/src/service-worker/router/` via grep, assert
each is a key in `ERROR_COPY`. Generated test or build-time check
preferred over a static snapshot so it can't drift.
### P1. Password Coloring
**Problem / Fix.** Implement per existing spec at
`docs/superpowers/specs/2026-05-01-password-coloring-design.md` and plan
at `docs/superpowers/plans/2026-05-01-password-coloring.md`. No design
changes; pulled into v0.5.0 because it's polish-flavored and small.
**Acceptance.** Per existing plan's acceptance criteria.
### P2. Setup-Wizard Completion → Fullscreen Vault Tab
**Problem.** Setup wizard currently routes to the popup item-list view
on completion. The user wants to land in the fullscreen vault tab —
the experience reads as more "real software" and avoids the sub-300px
popup width on first run.
**Fix.** In the setup wizard's terminal step, after the vault is
created and the device is registered:
1. Open the fullscreen vault tab via `chrome.tabs.create({ url:
chrome.runtime.getURL('vault.html') })`.
2. Close the setup tab (or the popup, depending on entry point).
**Acceptance.**
- Manual: complete the new-vault setup flow; vault tab opens, setup
tab closes.
- Manual: complete the attach-existing flow; same behavior.
- Vitest: assert `chrome.tabs.create` is called with the vault URL on
successful setup completion.
### P3. Form-Layout 2-col → Full-Width Transition
**Problem.** Screenshot 2026-05-02 shows the new-login form in the
fullscreen tab: the IDENTITY and CREDENTIALS cards sit in a 2-column
grid with a max-width that stops well short of viewport edge, but the
Notes textarea, custom-fields disclosure, and attachments disclosure
below all stretch full-width. The visual rhythm breaks at the
transition — the lower sections look like a different page.
**Fix.** Two candidate approaches; pick at implementation time:
- **A.** Constrain the lower sections to the same max-width and
horizontal alignment as the cards. They become a third "row" in the
same column system. Simpler, less code.
- **B.** Wrap the lower sections in a card matching the upper cards'
visual treatment. Notes-as-card, custom-fields-as-card,
attachments-as-card. More work, more visual consistency, but might
feel heavier.
Recommended: **A** for v0.5.0 — minimal CSS change, immediate
correctness. Revisit B in Phase 3 if the user still feels the layout
is off.
**Acceptance.**
- The new-login form (and all item-type forms — login, secure note,
identity, card, key, document, totp) renders with consistent
horizontal alignment from top of form to bottom.
- Manual: viewport at 1920×1080, 1440×900, 1024×768, and 768×1024;
no jarring width transitions.
---
## Testing Strategy
| Layer | Tests |
|---|---|
| Rust unit (`relicario-server`) | S1 acceptance set (4 scenarios) |
| Rust unit (`relicario-core`) | S2 path-traversal scenarios (3 scenarios) |
| CLI integration | S2 happy-path restore unchanged |
| Vitest (extension) | B1 input-event dispatch; P4 ERROR_COPY snapshot; P2 tabs.create call |
| Manual | P3 viewport sweep; B2/P4 trigger each error code; S1 hook setup on a real Gitea instance; C1 branch cleanup confirmation |
Existing tests stay green. No regression budget.
## Out of Scope
Listed for clarity; each tracked separately:
- LastPass import (`docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md`)
- Recovery QR + entropy floor (`docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md`)
- Plan 1C-γ (attachments + Document + trash UI)
- Fullscreen Phase 3 (shell) and Phase 4 (palette)
- Pre-v0.3.0 manual test walk (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) — that's a v0.3.0 release gate, not a v0.5.0 item
## Sequencing
1. Plan A and Plan B can run in parallel (no shared files).
2. Inside Plan A: S1 first (unblocks the security claim), then S2,
then S3, then C1.
3. Inside Plan B: P4 first (unblocks B2 and surfaces all error codes
centrally), then B1, then P1, then P3, then P2 (P2 is end-to-end
and benefits from earlier polish).
4. Both plans merge to main; tag `v0.5.0` after both PRs are green.
## Risks
- **S1 SSH allowed-signers parsing.** `git verify-commit --raw`
output format for SSH signatures differs slightly from GPG; the
fingerprint-extraction regex needs unit coverage against real
output. Mitigation: capture sample outputs from a test repo and
check them into the test fixtures.
- **P3 layout regressions.** Constraining the lower sections may
affect the textarea behavior at small widths. Mitigation: viewport
sweep in acceptance.
- **C1 accidental deletion.** Plan execution must `git branch
--merged main` filter and prompt before each `-D`. The
no-destructive-without-asking project rule applies.

View File

@@ -0,0 +1,357 @@
# v0.5.x — UX Polish, Settings Redesign & Recovery QR — Design
**Date:** 2026-05-03
**Status:** Draft
**Target:** v0.5.1 / next release train
## Overview
Three parallel streams building on the v0.5.0 base:
- **Stream A — Fullscreen + popup layout polish** — fullscreen vault tab gets a new 3-column layout (sidebar with type-category nav, full-width list, slide-in detail drawer); popup gets a polished type-picker; glyph additions; toast system; empty states.
- **Stream B — Settings UX redesign** — replace the current flat settings dump with a left-nav sectioned settings page; Security section with trusted-devices and Recovery QR integration.
- **Stream C — Recovery QR + setup wizard** — implement the recovery QR cryptographic feature (Rust core + WASM); integrate into the setup wizard's final step; wire into the vault-tab Security settings section.
Streams A and B share no files with Stream C (Rust/WASM). A and B share only `glyphs.ts` and `styles.css`; all other files are disjoint. All three can run in parallel.
---
## Stream A — Fullscreen + Popup Layout Polish
### A1. Fullscreen vault tab — 3-column layout
**Current state.** `extension/src/vault/vault.ts` renders a fixed sidebar (~220px) with brand, search, item list, and bottom nav buttons. Clicking `+ new item` navigates to the type-picker. The main pane shows the selected item in a single-column layout.
**New layout.**
```
┌─────────────┬──────────────────────────┬──────────────────────────┐
│ sidebar │ full-width list │ detail drawer (440px) │
│ (200px) │ (flex: 1) │ slides in on row click │
└─────────────┴──────────────────────────┴──────────────────────────┘
```
**Sidebar changes:**
- Replaces the current flat item list with **type-category nav**: all items are listed by section (Logins N, Secure Notes N, Cards N, Identities N, TOTP N, Keys N, Documents N) plus an "All items" entry at the top.
- Search bar stays above the category list.
- Bottom nav buttons remain ( new item, ▦ trash, ⌬ devices, ⚙ settings, ⏻ lock) — the `+ new item` button triggers the bottom sheet (see A3).
- `⧉` replaces the current `&#x2934;` pop-out button in the **popup toolbar only** — it stays in the popup toolbar and is not added to the fullscreen sidebar (you're already there).
**Full-width list:**
- Each row: 32px type icon (rounded, gold-tinted on selection) + title (13px) + subtitle (URL or type description, 11px muted) + `last-modified` age (10px dim, right-aligned).
- Clicking a row: highlights the row and slides in the detail drawer from the right. The list narrows to accommodate the 440px drawer — flex layout handles this naturally.
- Active row stays highlighted while drawer is open.
**Detail drawer (440px):**
- Header: type pill (e.g. `LOGIN`) left, action buttons right (`edit`, `history`, `copy pwd` where applicable), `✕` close.
- Body: title (18-20px bold) + subtitle (URL/description, muted), then a **2-column field grid** for sibling fields (username/password, first/last name, number/expiry, etc.). Full-width spans for URL, notes, address, and any field without a natural pair.
- Close (`✕` or Esc): drawer slides out, list returns to full width.
- At ≤ 720px viewport: drawer pushes full-page (list hidden), back breadcrumb `← <Section>` navigates back.
**Files affected:**
- `extension/src/vault/vault.ts` — full layout rewrite (sidebar list → category nav, main pane wiring, drawer state)
- `extension/src/vault/vault.css` — layout rules for 3-column, drawer, list rows, responsive breakpoint
### A2. Fullscreen vault tab — "new item" bottom sheet
**Current state.** Clicking `+ new item` in the sidebar sets `state.newType = null` and calls `renderPane()` which renders the type-picker inline in the main pane.
**New behaviour.** A bottom sheet slides up from the bottom edge of the **main pane** (pane-only scrim — sidebar stays interactive).
- Sheet structure: drag handle, "New item — choose type" label, 7-item type grid (Login, Secure Note, TOTP, Card, Identity, SSH/API Key, Document) as cards with large glyph (28px), name (11px muted). Selected type border turns gold on hover.
- Clicking a type: sheet closes, main pane renders the add form for that type.
- Dismissing (Esc, click scrim, `✕`): sheet closes, main pane returns to previous state.
- Scrim covers the main pane only (not the sidebar). Sidebar nav remains clickable.
**Files affected:**
- `extension/src/vault/vault.ts` — sheet trigger, render, dismiss logic
- `extension/src/vault/vault.css` — sheet, scrim, type-card styles
### A3. Popup — polished type-picker page
**Current state.** `+ new` button in the popup toolbar navigates directly to the `add` route. `renderItemForm` is called with `state.newType = null`, which presumably renders a type picker inline.
**New behaviour.** Keep the current navigation model (navigate to `add` route) but upgrade the type-picker page:
- Back arrow + "New item" title in the search-bar row (replacing search input).
- 2-column grid of type cards: icon (glyph, 20px), name (12px bold), description (10px muted). E.g. "Login / Username + password", "TOTP / 2FA token".
- Glyphs not emoji for type icons (use the per-type glyph table from A5).
- `Esc` navigates back to the list.
- Keyhint bar updates to show `Esc back`.
**Files affected:**
- `extension/src/popup/components/item-list.ts``+ new` button label/glyph, keyhint
- `extension/src/popup/components/item-form.ts` (or wherever the type picker lives) — card layout, glyphs
### A4. Glyphs
Add to `extension/src/shared/glyphs.ts`:
```ts
export const GLYPH_VAULT_TAB = '⧉'; // pop-out to fullscreen vault tab (replaces &#x2934;)
```
Remove the inline `&#x2934;` from `extension/src/popup/components/item-list.ts:69` and replace with `GLYPH_VAULT_TAB`.
### A5. Item row type icons
The popup item list (`buildRowsHtml` in `item-list.ts`) currently renders title-only rows with no visual type anchor. Add a per-type glyph to each row using the item's `ManifestEntry.type` field:
| Type | Glyph |
|------|-------|
| login | `◉` |
| secure_note | `◫` |
| totp | `⊡` |
| card | `▭` |
| identity | `⌬` |
| key | `⊹` |
| document | `≡` |
Icon: 26×26px, rounded, `--bg-elevated` fill, gold-tinted border on active row.
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/popup/styles.css`
### A6. Empty states
Two surfaces:
1. **Popup item list, vault empty** — centered message: glyph `◈` (28px dim), "No items yet", "Press `+` to add your first item."
2. **Popup item list, search returns nothing** — centered message: glyph `⊘` (28px dim), "No results for "{query}"", "Try a shorter search term."
3. **Fullscreen list pane, section empty** — same treatment scaled for the wider pane.
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/vault/vault.ts`
### A7. Toast notification system
Replace the current ad-hoc `sync-status` div with a shared toast system:
- `showToast(message: string, type: 'success' | 'error' | 'info', durationMs = 2500)` in `extension/src/shared/toast.ts`.
- Toasts appear bottom-center of the popup / bottom-right of the vault tab, auto-dismiss.
- Used for: sync success/failure, copy-to-clipboard confirmation, device registration success.
**Files affected:** new `extension/src/shared/toast.ts`, `extension/src/popup/styles.css`, `extension/src/vault/vault.css`, call sites in `item-list.ts` and `vault.ts`
---
## Stream B — Settings UX Redesign
### B1. Settings page structure
Replace the current flat settings dump (`settings.ts` + `settings-vault.ts`) with a unified settings page that renders within the fullscreen vault tab's main pane (and a compact equivalent in the popup).
**Left-nav sections:**
```
Device
⊙ Autofill
◈ Display
Vault
◉ Security ← Recovery QR + trusted devices (replaces devices.ts nav)
↻ Generator
▦ Retention
⤓ Backup
≡ Import
```
Each section renders its content in the right panel. The left nav is 148px; content area fills the remainder.
**Device vs Vault distinction:**
- "Device" sections read/write `chrome.storage.local` (per-browser settings).
- "Vault" sections read/write encrypted `VaultSettings` (shared across devices via git).
**Files affected:**
- `extension/src/popup/components/settings.ts` — rewrite as sectioned layout
- `extension/src/popup/components/settings-vault.ts` — content moves into new section components
**Note on vault.ts:** DEV-B delivers the settings component with a stable export signature. The `⚙ settings` nav wiring in `vault.ts` is updated as part of Stream A's vault.ts rewrite. DEV-A and DEV-B must agree on the component's export signature before either lands.
### B2. Autofill section (Device)
Content replaces the current flat settings dump:
- **Capture** group: "Auto-detect logins" toggle (was checkbox); "Prompt style" select (bar / toast).
- **Blocked sites** group: list of blacklisted hostnames, each with a remove button. Add-hostname input at bottom.
All options use the standardised `setting-row` pattern: left (title + description), right (control).
### B3. Display section (Device)
Moves the existing password-coloring UI (digit color picker, symbol color picker, live swatch, reset) from its current location into a proper Display section card.
### B4. Security section (Vault)
**Recovery QR card** (three states, see Stream C for implementation):
- **State 1 — no QR:** amber warning ("▲ No recovery QR generated — losing your reference image would make this vault unrecoverable"), single "Generate recovery QR…" button.
- **State 2 — QR exists, at rest:** green status ("◉ Recovery QR is set up"), last-generated date. Buttons: "Show / print QR…" and "Regenerate…". **No QR is visible in this state.**
- **State 3 — explicit view:** modal overlay (scrim over main pane only). QR rendered at ~140×140px. Warning: "▲ Close this window before stepping away. This QR is only displayed, never saved." Actions: "⎙ Print" (triggers `window.print()` scoped to modal) and "Done" (dismisses).
**Trusted devices** group: subsumes the current `⌬ devices` sidebar nav entry. Each registered device shows name, registration date, fingerprint, and a revoke button. "Register this device" entry for unregistered browsers. Once Stream B lands, the `⌬ devices` button is removed from the vault sidebar nav (settings → Security replaces it).
### B5. Generator section (Vault)
Pulls the existing generator-defaults content from `settings-vault.ts` into the new section layout. No functional changes — just consistent styling.
### B6. Retention section (Vault)
Pulls the existing retention content (trash retention, field history retention). No functional changes.
### B7. Backup section (Vault)
Pulls the existing backup & restore section. No functional changes.
### B8. Import section (Vault)
Pulls the existing import section. No functional changes.
---
## Stream C — Recovery QR
### C1. Rust core — `relicario-core/src/recovery_qr.rs`
Per the existing spec at `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`. Key implementation points:
**KDF input:**
```
b"relicario-recovery-v1\0" || u64_be(len(nfc(passphrase))) || nfc(passphrase)
```
Fed to Argon2id with production params (`m=64MiB, t=3, p=4`), fresh 32-byte salt per generation.
**Wrap:** `XChaCha20-Poly1305(wrap_key, nonce=OsRng(24), image_secret)` — 32+16=48 bytes ciphertext.
**Binary payload (109 bytes):**
```
[magic "RREC" 4B][version 0x01 1B][salt 32B][nonce 24B][ciphertext 48B]
```
**QR encoding:** byte mode, error-correction M, version 6 (41×41 modules). Library: `qrcode` crate (already in workspace or add it).
**API surface:**
```rust
pub struct RecoveryQrPayload { /* opaque */ }
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload, RelicarioError>;
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String;
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>, RelicarioError>;
```
The payload bytes are never written to disk by this module — callers are responsible for rendering only.
**Passphrase entropy floor:** enforce `zxcvbn score ≥ 3` at vault init in the CLI and the setup wizard (already gated in the extension by 1C-α; confirm CLI `create` command applies the same gate).
**Files affected:**
- `crates/relicario-core/src/recovery_qr.rs` — new module
- `crates/relicario-core/src/lib.rs` — pub mod recovery_qr
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` error variants if needed
- `crates/relicario-core/Cargo.toml` — add `qrcode` crate
- `crates/relicario-core/tests/` — new `recovery_qr.rs` test file
### C2. CLI — `relicario recovery-qr` subcommand group
```
relicario recovery-qr generate # prompts passphrase, renders QR to terminal (kitty/iTerm2 inline protocol or ASCII fallback)
relicario recovery-qr unwrap # prompts passphrase, prints image_secret as hex
```
`generate` never writes a file. It renders the QR inline in the terminal using the Kitty graphics protocol if `$TERM` indicates support, falling back to ASCII art via the `qrcode` crate's built-in ASCII renderer.
**Files affected:** `crates/relicario-cli/src/main.rs`
### C3. WASM bindings
```ts
// relicario-wasm/src/lib.rs
generate_recovery_qr(passphrase: &str, image_secret: &[u8]) -> Result<String, JsValue> // returns SVG string
unwrap_recovery_qr(payload_b64: &str, passphrase: &str) -> Result<Vec<u8>, JsValue> // returns image_secret bytes
```
**Files affected:** `crates/relicario-wasm/src/lib.rs`, `crates/relicario-wasm/Cargo.toml`
### C4. Extension — Recovery QR in Security settings
Implement the three-state Security section card described in B4:
- State determined by `chrome.storage.local.recovery_qr_generated_at` (timestamp or null).
- "Generate recovery QR…" button: calls WASM `generate_recovery_qr(passphrase, image_secret)` → stores `recovery_qr_generated_at = Date.now()` in local storage → transitions to State 3 (show modal with SVG).
- "Show / print QR…" button: re-derives QR (requires vault to be unlocked, master key in session) → shows State 3 modal.
- "Regenerate…" button: same as generate, with a confirmation step first.
- Print: injects SVG into a `<iframe>` styled for print, calls `iframe.contentWindow.print()`.
**Files affected:**
- New `extension/src/popup/components/settings-security.ts`
- `extension/src/popup/components/settings.ts` — wire Security section
### C5. Extension — Recovery QR in setup wizard (Step 5 "Done")
The wizard's final step adds a **skippable banner** above the "Download reference image" button:
```
◫ Generate a recovery QR before you go
If you lose your reference image, this QR lets you recover your vault.
[Generate now] [Skip — I'll do this in Settings]
```
- "Generate now": calls WASM → shows QR modal inline on the wizard page. After dismissing, banner becomes green "◉ Recovery QR generated".
- "Skip": dismisses banner permanently for this session; user can generate later from Settings → Security.
- The banner is informational, not a blocker. Vault is fully usable without a recovery QR.
**Files affected:** `extension/src/setup/setup.ts`
### C6. Setup wizard redesign (Style C)
Redesign the setup wizard from the current single-column glass-card layout to **Style C (centered hero card)**:
- Full-page dark background (`--bg-page`).
- Relicario logo glyph + wordmark centered at top.
- **Colored progress track**: 5 segments, `--success` fill for completed, `--gold` for current, `--border` for pending.
- Centered card (max-width 560px): step eyebrow label ("Step N of 5 · <step name>"), h2 heading, hint text, form content, action row.
- **Glyphs not emoji** throughout. Mode cards use `◈` (create new) and `⌥` (attach). Mode-card glyphs at 28px. All other icons from the existing glyph set.
- Probe-banner success state uses `◉` (filled circle, matches ⊙/⊘ family).
- Action row: "◂ back" text button (left), "Continue ▸" primary button (right).
This is a pure CSS/markup change — no logic changes.
**Files affected:** `extension/src/setup/setup.ts`, setup CSS (inline or extracted)
---
## Responsive behaviour
| Viewport | Fullscreen behaviour |
|---|---|
| ≥ 960px | 3-column: sidebar + list + drawer |
| 720960px | 2-column: sidebar + list; drawer pushes full-pane on click |
| ≤ 720px | Sidebar collapses (hamburger/icon strip); list full-width; detail is full-page push |
The popup is always narrow (~340px) — popup-specific components are unaffected by the fullscreen responsive rules.
---
## Acceptance criteria (shared)
- `cargo test` green. `bun run test` green. `bun run build` + `bun run build:firefox` clean.
- No raw `snake_case` error codes in any UI surface.
- No emoji in any UI surface — all icons are Unicode monochrome glyphs.
- `glyphs.ts` is the single source of truth for all icon constants; no inline Unicode literals at call sites.
- QR code is never written to any file, `chrome.storage`, or git. `recovery_qr_generated_at` (timestamp only) is the only persisted artifact.
- Settings left-nav sections all render without console errors. Device sections read/write `chrome.storage.local`. Vault sections read/write `VaultSettings`.
---
## Stream split summary (for multi-agent kickoff)
| Stream | Owner | Core files | Dependency |
|---|---|---|---|
| A — Fullscreen + popup layout | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `glyphs.ts` | none |
| B — Settings UX | DEV-B | `settings.ts`, `settings-vault.ts`, new `settings-security.ts` | waits for C4 interface (can stub) |
| C — Recovery QR | DEV-C | `recovery_qr.rs`, `relicario-wasm/src/lib.rs`, `setup.ts`, `settings-security.ts` | none |
B and C share `settings-security.ts` — DEV-C owns the file, DEV-B wires it into the nav. Coordinate on interface (component export signature) before DEV-B proceeds with B4.

View File

@@ -1,13 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<defs> <defs>
<radialGradient id="redThecaSm" cx="0.4" cy="0.35"> <radialGradient id="redThecaSm" cx="0.4" cy="0.35">
<stop offset="0%" stop-color="#9a1a1a"/> <stop offset="0%" stop-color="#7d2622"/>
<stop offset="100%" stop-color="#3a0a0a"/> <stop offset="100%" stop-color="#2c0d0a"/>
</radialGradient> </radialGradient>
<linearGradient id="goldRingSm" x1="0" x2="1"> <linearGradient id="goldRingSm" x1="0" x2="1">
<stop offset="0%" stop-color="#d2ab43"/> <stop offset="0%" stop-color="#a88a4a"/>
<stop offset="50%" stop-color="#f5d97a"/> <stop offset="50%" stop-color="#cdb47a"/>
<stop offset="100%" stop-color="#7c5719"/> <stop offset="100%" stop-color="#5a3f12"/>
</linearGradient> </linearGradient>
</defs> </defs>
@@ -15,13 +15,13 @@
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/> <circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/> <circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
<!-- Asterisk-as-3-bars --> <!-- Asterisk-as-3-bars (translucent) -->
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round"> <g transform="translate(8, 9)" stroke="#dac8a0" stroke-width="1.2" stroke-linecap="round" stroke-opacity="0.8">
<line x1="0" y1="-3" x2="0" y2="3"/> <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"/>
<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> </g>
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/> <circle cx="8" cy="9" r="0.7" fill="#dac8a0"/>
<!-- Fleur (3 tips) --> <!-- 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 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,79 +1,93 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
<defs> <defs>
<radialGradient id="redTheca" cx="0.4" cy="0.35"> <radialGradient id="redTheca" cx="0.4" cy="0.35">
<stop offset="0%" stop-color="#9a1a1a"/> <stop offset="0%" stop-color="#7d2622"/>
<stop offset="100%" stop-color="#3a0a0a"/> <stop offset="100%" stop-color="#2c0d0a"/>
</radialGradient> </radialGradient>
<linearGradient id="goldRing" x1="0" x2="1"> <linearGradient id="goldRing" x1="0" x2="1">
<stop offset="0%" stop-color="#d2ab43"/> <stop offset="0%" stop-color="#a88a4a"/>
<stop offset="50%" stop-color="#f5d97a"/> <stop offset="50%" stop-color="#cdb47a"/>
<stop offset="100%" stop-color="#7c5719"/> <stop offset="100%" stop-color="#5a3f12"/>
</linearGradient> </linearGradient>
<linearGradient id="goldHi" x1="0" x2="1"> <linearGradient id="goldHi" x1="0" x2="1">
<stop offset="0%" stop-color="#fde9a8"/> <stop offset="0%" stop-color="#dac8a0"/>
<stop offset="100%" stop-color="#d2ab43"/> <stop offset="100%" stop-color="#a88a4a"/>
</linearGradient> </linearGradient>
<linearGradient id="gemFacetLight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dac8a0" stop-opacity="0.8"/>
<stop offset="100%" stop-color="#cdb47a" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="gemFacetDark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6e4d18" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#5a3f12" stop-opacity="0.25"/>
</linearGradient>
<radialGradient id="gemCore" cx="0.4" cy="0.35">
<stop offset="0%" stop-color="#dac8a0" stop-opacity="0.85"/>
<stop offset="60%" stop-color="#b89556" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#7d2622" stop-opacity="0.2"/>
</radialGradient>
</defs> </defs>
<!-- Pedestal (compact) --> <!-- Pedestal -->
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/> <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="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
<rect x="98" y="202" width="24" height="12" 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="208" rx="14" ry="3" fill="#5a3f12"/>
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/> <ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
<!-- Body, bezel, theca --> <!-- Body, bezel, theca -->
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/> <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"/> <path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#dac8a0" stroke-width="2" fill="none" opacity="0.5"/>
<circle cx="110" cy="130" r="60" fill="#7c5719"/> <circle cx="110" cy="130" r="60" fill="#5a3f12"/>
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/> <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)"/> <ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.10" transform="rotate(-30 86 108)"/>
<!-- Asterisk gem with pinwheel facets --> <!-- Asterisk gem with translucent gradient facets -->
<g transform="translate(110, 130)"> <g transform="translate(110, 130)">
<g transform="rotate(0)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </g>
<g transform="rotate(60)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </g>
<g transform="rotate(120)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </g>
<g transform="rotate(180)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </g>
<g transform="rotate(240)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </g>
<g transform="rotate(300)"> <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="url(#gemFacetLight)"/>
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/> <path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
</g> </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"/> <polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="url(#gemCore)" stroke="#5a3f12" stroke-width="0.5" stroke-opacity="0.6"/>
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/> <circle cx="-1.8" cy="-2.2" r="1.6" fill="#dac8a0" opacity="0.95"/>
<circle cx="1.5" cy="2" r="0.6" fill="#dac8a0" opacity="0.5"/>
</g> </g>
<!-- Hinge collar --> <!-- Hinge collar -->
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/> <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"/> <line x1="100" y1="55" x2="120" y2="55" stroke="#5a3f12" stroke-width="0.8"/>
<!-- Fleur-de-lis --> <!-- Fleur-de-lis -->
<g transform="translate(110, 50)"> <g transform="translate(110, 50)">
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/> <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="-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"/> <rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#5a3f12"/>
<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 -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"/> <path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#5a3f12" opacity="0.55"/>
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/> <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)"/> <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)"/> <ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#5a3f12" 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)"/> <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)"/> <ellipse cx="25" cy="-44" rx="2" ry="3" fill="#5a3f12" opacity="0.4" transform="rotate(20 25 -44)"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "relicario", "name": "Relicario",
"version": "0.1.0", "version": "0.2.0",
"description": "Two-factor encrypted password manager", "description": "Two-factor encrypted password manager",
"icons": { "icons": {
"16": "icons/icon-16.png", "16": "icons/icon-16.png",

View File

@@ -0,0 +1,13 @@
// Stub for the runtime-only WASM module. Used by vitest so that modules
// importing relicario_wasm.js can be loaded in a Node/happy-dom environment.
// Individual tests that exercise WASM calls should mock the relevant exports.
export default async function init(): Promise<void> {}
export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); };
export const lock = (): void => {};
export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); };
export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); };
export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); };
export const default_vault_settings_json = (): string => '{}';
export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); };
export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); };

View File

@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
})); }));
import { sendMessage } from '../../../shared/state'; import { sendMessage } from '../../../shared/state';
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as any).chrome = {
storage: {
sync: {
get: vi.fn((key: string) => Promise.resolve(
key in store ? { [key]: store[key] } : {})),
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
},
};
return store;
}
function settingsResponses() { function settingsResponses() {
// Two parallel calls in renderSettings: get_settings + get_blacklist. // Two parallel calls in renderSettings: get_settings + get_blacklist.
@@ -30,6 +46,7 @@ describe('settings view', () => {
}); });
it('renders a Sync now button', async () => { it('renders a Sync now button', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
await renderSettings(app); await renderSettings(app);
@@ -38,6 +55,7 @@ describe('settings view', () => {
}); });
it('clicking Sync now sends a sync message and shows feedback on success', async () => { it('clicking Sync now sends a sync message and shows feedback on success', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true }); (sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
@@ -52,6 +70,7 @@ describe('settings view', () => {
}); });
it('shows the error when sync fails', async () => { it('shows the error when sync fails', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' }); (sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
@@ -64,3 +83,109 @@ describe('settings view', () => {
expect(status.textContent).toMatch(/remote_unreachable/); expect(status.textContent).toMatch(/remote_unreachable/);
}); });
}); });
describe('settings Display section', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
});
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput).not.toBeNull();
expect(symbolInput).not.toBeNull();
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
});
it('renders pickers with stored values when storage has a scheme', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput!.value).toBe('#112233');
expect(symbolInput!.value).toBe('#aabbcc');
});
it('renders a color-preview-swatch element', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
expect(app.querySelector('#display-swatch')).not.toBeNull();
});
it('changing digit color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
digitInput.value = '#ff0000';
digitInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
}),
);
});
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
symbolInput.value = '#00ff00';
symbolInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
}),
);
});
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
resetBtn.click();
await new Promise((r) => setTimeout(r, 0));
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderUnlock } from '../unlock';
vi.mock('../../../shared/state', () => ({
getState: () => ({ loading: false, error: null }),
setState: vi.fn(),
sendMessage: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
openVaultTab: vi.fn(),
}));
describe('renderUnlock', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
});
it('renders the logo lockup (logo + brand + tagline)', () => {
renderUnlock(app);
expect(app.querySelector('.brand-logo')).toBeTruthy();
expect(app.querySelector('.brand')?.textContent).toBe('Relicario');
expect(app.querySelector('.tagline')?.textContent).toContain('two-factor');
});
it('renders the unlock form inside a .glass card', () => {
renderUnlock(app);
const glass = app.querySelector('.glass');
expect(glass).toBeTruthy();
expect(glass!.querySelector('#passphrase-input')).toBeTruthy();
expect(glass!.querySelector('.btn-primary')).toBeTruthy();
});
it('renders open-vault and settings as secondary buttons outside the card', () => {
renderUnlock(app);
const vaultBtn = app.querySelector('#vault-btn');
const settingsBtn = app.querySelector('#settings-btn');
expect(vaultBtn?.classList.contains('btn-secondary')).toBe(true);
expect(settingsBtn?.classList.contains('btn-secondary')).toBe(true);
// They should NOT be inside the .glass card
const glass = app.querySelector('.glass');
expect(glass!.contains(vaultBtn!)).toBe(false);
});
});

View File

@@ -3,6 +3,13 @@
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types'; import type { Device } from '../../shared/types';
interface RevokedEntry {
name: string;
public_key: string;
revoked_at: number;
revoked_by: string;
}
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec; const diff = now - unixSec;
@@ -36,16 +43,62 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
const stored = await chrome.storage.local.get(['device_name']); const stored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = stored.device_name as string | undefined; const currentDeviceName: string | undefined = stored.device_name as string | undefined;
// Fetch device list // Fetch active device list and revoked list in parallel
const resp = await sendMessage({ type: 'list_devices' }); const [devicesResp, revokedResp] = await Promise.all([
if (!resp.ok) { sendMessage({ type: 'list_devices' }),
sendMessage({ type: 'list_revoked' }),
]);
if (!devicesResp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`; app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
return; return;
} }
const devices = (resp.data as { devices: Device[] }).devices; const devices = (devicesResp.data as { devices: Device[] }).devices;
const revokedDevices: RevokedEntry[] = revokedResp.ok
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
: [];
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
</div>
`;
}).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
<details class="revoked-section" style="margin-top:16px;">
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
</summary>
<div style="margin-top:8px;">
${revokedDevices.map((r) => `
<div class="device-row device-row--revoked">
<div class="device-row__info">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
${escapeHtml(r.name)}
</span>
<span class="device-row__meta">
revoked ${relativeTime(r.revoked_at)}
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
</span>
</div>
</div>
`).join('')}
</div>
</details>
`;
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div class="devices-header"> <div class="devices-header">
@@ -58,20 +111,8 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
<button class="btn btn-primary" id="register-btn">Register this device</button> <button class="btn btn-primary" id="register-btn">Register this device</button>
</div> </div>
` : ''} ` : ''}
${devices.length === 0 ${activeDevicesHtml}
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>` ${revokedSectionHtml}
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
</div>
`;
}).join('')}
</div> </div>
`; `;

View File

@@ -1,6 +1,7 @@
/// Field history view — shows password/concealed field history for an item. /// Field history view — shows password/concealed field history for an item.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { FieldHistoryView } from '../../shared/types'; import type { FieldHistoryView } from '../../shared/types';
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
@@ -103,6 +104,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
</div> </div>
`; `;
// Colorize revealed entries: replace plain-text content with colorized spans
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
const plaintext = valueStore.get(key);
if (plaintext !== undefined) {
el.textContent = '';
el.appendChild(colorizePassword(plaintext));
}
});
// Wire handlers // Wire handlers
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail')); app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));

View File

@@ -6,6 +6,7 @@
/// copy click handlers on any rendered rows. /// copy click handlers on any rendered rows.
import { escapeHtml } from '../../shared/state'; import { escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { Item, Section, Field, FieldValue } from '../../shared/types'; import type { Item, Section, Field, FieldValue } from '../../shared/types';
export interface RowOpts { export interface RowOpts {
@@ -46,6 +47,7 @@ export interface ConcealedRowOpts {
id: string; id: string;
label: string; label: string;
value: string; value: string;
kind?: 'password' | 'concealed';
monospace?: boolean; monospace?: boolean;
multiline?: boolean; multiline?: boolean;
} }
@@ -53,12 +55,15 @@ export interface ConcealedRowOpts {
/// Concealed row — value rendered hidden until the user clicks "show". /// Concealed row — value rendered hidden until the user clicks "show".
/// Plaintext is stored in `data-field-value` on the row element and copied /// Plaintext is stored in `data-field-value` on the row element and copied
/// to the visible value span on reveal. Copy button always copies plaintext. /// to the visible value span on reveal. Copy button always copies plaintext.
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
/// reveal so digits/symbols/letters are rendered in distinct colours.
export function renderConcealedRow(opts: ConcealedRowOpts): string { export function renderConcealedRow(opts: ConcealedRowOpts): string {
const { id, label, value, monospace, multiline } = opts; const { id, label, value, kind, monospace, multiline } = opts;
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••'; const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`; const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
return ` return `
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"> <div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
<span class="field-row__label">${escapeHtml(label)}</span> <span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span> <span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
<span class="field-row__actions"> <span class="field-row__actions">
@@ -101,7 +106,13 @@ export function wireFieldHandlers(scope: HTMLElement): void {
row.setAttribute('data-revealed', 'false'); row.setAttribute('data-revealed', 'false');
btn.textContent = 'show'; btn.textContent = 'show';
} else { } else {
valueEl.textContent = plaintext; const isPassword = row.getAttribute('data-field-kind') === 'password';
valueEl.textContent = '';
if (isPassword) {
valueEl.appendChild(colorizePassword(plaintext));
} else {
valueEl.textContent = plaintext;
}
row.setAttribute('data-revealed', 'true'); row.setAttribute('data-revealed', 'true');
btn.textContent = 'hide'; btn.textContent = 'hide';
} }
@@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string {
id: `${idPrefix}-s${sIdx}-f${fIdx}`, id: `${idPrefix}-s${sIdx}-f${fIdx}`,
label: field.label, label: field.label,
value: field.value.value, value: field.value.value,
kind: field.value.kind,
}); });
} }
}); });

View File

@@ -6,6 +6,7 @@
import { sendMessage } from '../../shared/state'; import { sendMessage } from '../../shared/state';
import type { GeneratorRequest, VaultSettings } from '../../shared/types'; import type { GeneratorRequest, VaultSettings } from '../../shared/types';
import { colorizePassword } from '../../shared/password-coloring';
interface UiKnobs { interface UiKnobs {
kind: 'random' | 'bip39'; kind: 'random' | 'bip39';
@@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void {
const d = resp.data as { password?: string; passphrase?: string }; const d = resp.data as { password?: string; passphrase?: string };
currentPreview = d.password ?? d.passphrase ?? ''; currentPreview = d.password ?? d.passphrase ?? '';
const el = host.querySelector('.preview__value'); const el = host.querySelector('.preview__value');
if (el) el.textContent = currentPreview; if (el) {
el.textContent = '';
el.appendChild(colorizePassword(currentPreview));
}
updateValidation(); updateValidation();
} }
}, 150); }, 150);

View File

@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const type: ItemType = existing?.type ?? state.newType ?? 'login'; const type: ItemType = existing?.type ?? state.newType ?? 'login';
switch (type) { switch (type) {
case 'login': return login.renderForm(app, mode, existing); case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup', externalActions: isInTab() });
case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'secure_note': return secureNote.renderForm(app, mode, existing);
case 'identity': return identity.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing);
case 'card': return card.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing);

View File

@@ -0,0 +1,329 @@
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
///
/// Exported contract:
/// renderSecuritySection(container, sessionHandle): renders into `container`
/// teardownSecuritySection(): removes any open QR modal
import { sendMessage, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
// --- Relative time helper ---
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
// --- Modal helpers ---
const MODAL_ID = 'relicario-qr-modal';
function removeModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function showQrModal(svgContent: string): void {
removeModal();
const overlay = document.createElement('div');
overlay.id = MODAL_ID;
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:9999',
'background:rgba(0,0,0,0.85)',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'padding:16px', 'box-sizing:border-box',
].join(';');
overlay.innerHTML = `
<div style="
background:#161b22; border:1px solid #30363d; border-radius:8px;
padding:16px; max-width:340px; width:100%; text-align:center;
">
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
Recovery QR
</div>
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
Print or store this QR. It encodes your reference image secret,
protected by your passphrase.
</div>
<div id="relicario-qr-svg" style="
background:#fff; border-radius:4px; padding:8px;
display:inline-block; max-width:280px; width:100%;
">
${svgContent}
</div>
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
Print
</button>
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
Done
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
const win = window.open('', '_blank', 'width=400,height=500');
if (!win) return;
win.document.write(`
<!DOCTYPE html>
<html><head><title>Recovery QR</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center;
font-family: sans-serif; padding: 24px; }
h2 { font-size: 16px; margin-bottom: 8px; }
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
svg { max-width: 280px; width: 100%; }
</style></head><body>
<h2>Relicario Recovery QR</h2>
<p>Scan with the Relicario app to recover your reference image secret.<br>
Keep this page in a safe physical location.</p>
${svgContent}
<script>window.onload = () => { window.print(); window.close(); }<\/script>
</body></html>
`);
win.document.close();
});
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) removeModal();
});
}
// --- Main render ---
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void> {
// Read timestamp from device-local storage (never the QR payload itself)
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
const isUnlocked = sessionHandle !== null;
// --- QR status section ---
let qrStatusHtml: string;
if (generatedAt === null) {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">⚠</span>
<div style="flex:1; font-size:12px;">
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
No recovery QR generated
</div>
<div style="color:#8b949e;">
If you lose access to your reference image, you will be locked out permanently.
</div>
</div>
</div>
<button
class="btn btn-primary"
id="sec-generate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="width:100%; font-size:12px; margin-bottom:4px;"
>
Generate recovery QR…
</button>
`;
} else {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">✓</span>
<div style="flex:1; font-size:12px;">
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
Recovery QR set up
</div>
<div style="color:#8b949e;">
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:4px;">
<button
class="btn"
id="sec-show-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Show / print QR…
</button>
<button
class="btn"
id="sec-regenerate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Regenerate…
</button>
</div>
`;
}
// --- Devices section ---
const devicesResp = await sendMessage({ type: 'list_devices' });
let devicesHtml: string;
if (!devicesResp.ok) {
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
} else {
const devices = (devicesResp.data as { devices: Device[] }).devices;
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
if (devices.length === 0) {
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
} else {
devicesHtml = devices.map((d) => {
const isCurrent = d.name === currentDeviceName;
return `
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
<div style="flex:1; min-width:0;">
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
</div>
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
</div>
${isCurrent ? '' : `
<button
class="btn sec-revoke-btn"
data-device-name="${escapeHtml(d.name)}"
style="font-size:11px; margin-left:8px; flex-shrink:0;"
>revoke</button>
`}
</div>
`;
}).join('');
}
}
// --- Assemble ---
container.innerHTML = `
<div class="settings-section" style="margin-top:0;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Recovery QR
</div>
${qrStatusHtml}
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
</div>
<div class="settings-section" style="margin-top:16px;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Trusted Devices
</div>
<div id="sec-devices-list">
${devicesHtml}
</div>
</div>
`;
// --- Wire handlers ---
const setQrError = (msg: string): void => {
const el = document.getElementById('sec-qr-error');
if (el) el.textContent = msg;
};
async function doGenerateQr(isRegen: boolean): Promise<void> {
const passphrase = prompt(
isRegen
? 'Enter your vault passphrase to regenerate the recovery QR:'
: 'Enter your vault passphrase to generate the recovery QR:',
);
if (!passphrase) return;
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
return;
}
const svg = (resp.data as { svg: string }).svg;
const now = Math.floor(Date.now() / 1000);
// Store only the timestamp, NEVER the QR payload
await chrome.storage.local.set({ recovery_qr_generated_at: now });
showQrModal(svg);
// Re-render to reflect new state (timestamp now exists)
await renderSecuritySection(container, sessionHandle);
}
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
void doGenerateQr(false);
});
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
void doGenerateQr(true);
});
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
if (!passphrase) return;
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
return;
}
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
const svg = (resp.data as { svg: string }).svg;
showQrModal(svg);
});
// Revoke buttons
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.dataset.deviceName;
if (!name) return;
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
btn.disabled = true;
btn.textContent = '…';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
// Re-render to refresh device list
await renderSecuritySection(container, sessionHandle);
} else {
btn.disabled = false;
btn.textContent = 'revoke';
setQrError(`Revoke failed: ${result.error}`);
}
});
});
}
export function teardownSecuritySection(): void {
removeModal();
}

View File

@@ -7,6 +7,7 @@ import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types'; } from '../../shared/types';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { GLYPH_NEXT } from '../../shared/glyphs';
let pendingSettings: VaultSettings | null = null; let pendingSettings: VaultSettings | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
@@ -161,14 +162,14 @@ export function renderVaultSettings(app: HTMLElement): void {
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div> <div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore </button> <button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">import</div> <div class="settings-section__title">import</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-import">LastPass CSV </button> <button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,11 @@
import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types'; import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs'; import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
import {
loadColorScheme, saveColorScheme, resetColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../../shared/color-scheme';
import { colorizePassword } from '../../shared/password-coloring';
export async function renderSettings(app: HTMLElement): Promise<void> { export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>'; app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div> <div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div> </div>
<div style="margin-bottom:16px;" id="display-section-container">
</div>
<div> <div>
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div> <div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
<div id="blacklist-container"> <div id="blacklist-container">
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
} }
}); });
}); });
// Render Display section after the rest of the DOM is ready
await renderDisplaySection();
}
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
swatch.innerHTML = '';
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
}
async function renderDisplaySection(): Promise<void> {
// The Display section container must be present in the DOM before we call this
const container = document.getElementById('display-section-container');
if (!container) return;
const scheme = await loadColorScheme();
container.innerHTML = `
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
digit color
</label>
</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
symbol color
</label>
</div>
<div id="display-swatch" class="color-preview-swatch"></div>
<div style="margin-top:8px;">
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
</div>
`;
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
const swatch = document.getElementById('display-swatch') as HTMLElement;
// Render initial swatch
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
async function onColorChange(): Promise<void> {
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
await saveColorScheme(newScheme);
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
}
digitInput.addEventListener('change', () => void onColorChange());
symbolInput.addEventListener('change', () => void onColorChange());
document.getElementById('display-reset')?.addEventListener('click', async () => {
await resetColorScheme();
digitInput.value = DEFAULT_DIGIT_COLOR;
symbolInput.value = DEFAULT_SYMBOL_COLOR;
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
});
} }

View File

@@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({
entropyText: vi.fn(() => ''), entropyText: vi.fn(() => ''),
})); }));
import { renderForm } from '../login'; import { renderForm, applyGeneratedPassword } from '../login';
import { sendMessage } from '../../../../shared/state'; import { sendMessage } from '../../../../shared/state';
describe('login form smart inputs', () => { describe('login form smart inputs', () => {
@@ -63,6 +63,40 @@ describe('login form smart inputs', () => {
}); });
}); });
describe('renderForm surface flag', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
(globalThis as any).chrome = {
storage: {
local: {
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
},
},
runtime: {
sendMessage: vi.fn(),
},
};
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
});
it('renders single-column when surface is "popup" (default)', () => {
renderForm(app, 'add', null);
expect(app.querySelector('.form-grid')).toBeNull();
});
it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => {
renderForm(app, 'add', null, { surface: 'fullscreen' });
const grid = app.querySelector('.form-grid');
expect(grid).toBeTruthy();
expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy();
expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy();
});
});
describe('Login save shape', () => { describe('Login save shape', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'; document.body.innerHTML = '<div id="app"></div>';
@@ -120,3 +154,37 @@ describe('Login save shape', () => {
expect(addCall).toBeUndefined(); expect(addCall).toBeUndefined();
}); });
}); });
describe('regenerate handler dispatches input event', () => {
it('dispatches an InputEvent on the input after value is set', () => {
const input = document.createElement('input');
input.type = 'password';
document.body.appendChild(input);
const dispatchSpy = vi.spyOn(input, 'dispatchEvent');
applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%');
expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%');
expect(input.type).toBe('text');
expect(dispatchSpy).toHaveBeenCalled();
const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent;
expect(evt).toBeDefined();
expect(evt.type).toBe('input');
expect(evt.bubbles).toBe(true);
document.body.removeChild(input);
});
it('bubbling listener fires when applyGeneratedPassword is called', () => {
const input = document.createElement('input');
document.body.appendChild(input);
let listenerFired = false;
input.addEventListener('input', () => { listenerFired = true; });
applyGeneratedPassword(input, 'newpass');
expect(listenerFired).toBe(true);
document.body.removeChild(input);
});
});

View File

@@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools'; import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
import { scheduleRate } from '../../../setup/setup-helpers'; import { scheduleRate } from '../../../setup/setup-helpers';
/// Sets a generated password on an input, reveals it as plain text, then
/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter)
/// re-evaluate the new value.
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
input.value = value;
input.type = 'text';
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
}
/// Called by the dispatcher before each render. Stops any in-flight /// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached. /// tickers / intervals / listeners the previous view may have attached.
export function teardown(): void { export function teardown(): void {
@@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
${renderSignatureBlock({ accent: 'gold', children: sigInner })} ${renderSignatureBlock({ accent: 'gold', children: sigInner })}
</div> </div>
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''} ${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })} ${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })}
${url ? renderRow({ label: 'url', value: url, href: url }) : ''} ${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
${hasTotp ? ` ${hasTotp ? `
<div class="field-row"> <div class="field-row">
@@ -235,7 +244,20 @@ function startTotpTicker(id: ItemId): void {
// Form (add / edit) // Form (add / edit)
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { export interface RenderFormOptions {
surface?: 'popup' | 'fullscreen';
/** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */
externalActions?: boolean;
}
export function renderForm(
app: HTMLElement,
mode: 'add' | 'edit',
existing: Item | null,
opts: RenderFormOptions = {}
): void {
const surface = opts.surface ?? 'popup';
const externalActions = opts.externalActions ?? false;
const state = getState(); const state = getState();
const existingCore = (existing?.core.type === 'login') const existingCore = (existing?.core.type === 'login')
? (existing.core as LoginCore & { type: 'login' }) ? (existing.core as LoginCore & { type: 'login' })
@@ -254,72 +276,102 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
: []; : [];
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
const titleFieldHtml = `
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>`;
const urlFieldHtml = `
<div class="form-group">
<label class="label" for="f-url">url</label>
<div class="inline-row">
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
</div>
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
</div>`;
const groupFieldHtml = `
<div class="form-group"><label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>`;
const usernameFieldHtml = `
<div class="form-group"><label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
const passwordFieldHtml = `
<div class="form-group">
<label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}">
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
</div>
<div id="strength-bar-row" class="strength-bar-row" hidden>
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
<div class="strength-label"></div>
</div>
</div>`;
const totpFieldHtml = `
<div class="form-group">
<label class="label" for="f-totp">totp secret (base32)</label>
<div class="inline-row">
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
</div>
<div id="totp-preview-row" class="totp-preview" hidden>
<span class="totp-code">…</span>
<span class="totp-countdown">…</span>
</div>
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
<input id="totp-qr-file" type="file" accept="image/*" />
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
<div id="totp-qr-error" class="totp-qr-error"></div>
</div>
</div>`;
const identityHtml = `
<div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''}
${titleFieldHtml}
${urlFieldHtml}
${groupFieldHtml}
</div>`;
const credentialsHtml = `
<div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''}
${usernameFieldHtml}
${passwordFieldHtml}
${totpFieldHtml}
</div>`;
const sectionsHtml = surface === 'fullscreen'
? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>`
: `${identityHtml}${credentialsHtml}`;
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} ${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label> ${sectionsHtml}
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"> <div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
<label class="label" for="f-url">url</label> <div class="form-group">
<div class="inline-row"> <div class="notes-with-toggle">
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"> <label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab"></button> <button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace"></button>
</div>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
</div> </div>
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
</div>
<div class="form-group"><label class="label" for="f-username">username</label> ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div> ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
<div class="form-group"> <button class="btn" id="cancel-btn">cancel</button>
<label class="label" for="f-password">password</label> <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}">
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
</div> </div>
<div id="strength-bar-row" class="strength-bar-row" hidden>
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
<div class="strength-label"></div>
</div>
</div>
<div class="form-group">
<label class="label" for="f-totp">totp secret (base32)</label>
<div class="inline-row">
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
</div>
<div id="totp-preview-row" class="totp-preview" hidden>
<span class="totp-code">…</span>
<span class="totp-countdown">…</span>
</div>
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
<input id="totp-qr-file" type="file" accept="image/*" />
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
<div id="totp-qr-error" class="totp-qr-error"></div>
</div>
</div>
<div class="form-group"><label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
<div class="form-group">
<div class="notes-with-toggle">
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
</div>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
</div>
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div> </div>
</div> </div>
`; `;
@@ -392,7 +444,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
context: 'fill-field', context: 'fill-field',
onPicked: (value) => { onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null; const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) { pw.value = value; pw.type = 'text'; } if (pw) applyGeneratedPassword(pw, value);
}, },
}); });
}); });
@@ -433,7 +485,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e
} }
} }
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> { export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
const state = getState(); const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;

View File

@@ -7,54 +7,63 @@ export function renderUnlock(app: HTMLElement): void {
const state = getState(); const state = getState();
app.innerHTML = ` app.innerHTML = `
<div class="pad" style="text-align:center; padding-top:40px;"> <div class="pad" style="text-align:center; padding-top:32px;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt=""> <div class="logo-lockup" style="margin-bottom:24px;">
<div class="brand">Relicario</div> <img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p> <div class="brand">Relicario</div>
<div class="form-group"> <p class="tagline">two-factor vault</p>
<input
type="password"
id="passphrase-input"
placeholder="passphrase"
autocomplete="off"
${state.loading ? 'disabled' : ''}
>
</div> </div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} <div class="glass" style="padding:16px; text-align:left; margin-bottom:16px;">
<div style="margin-top:24px;"> <div class="card-label" style="font-size:10px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-muted);margin-bottom:8px;">unlock</div>
<button class="btn" id="vault-btn" style="font-size:11px;">open vault</button> <div class="form-group" style="margin-bottom:10px;">
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button> <input
type="password"
id="passphrase-input"
placeholder="passphrase"
autocomplete="off"
${state.loading ? 'disabled' : ''}
>
</div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<button class="btn-primary" id="unlock-btn" style="width:100%;justify-content:center;" ${state.loading ? 'disabled' : ''}>unlock vault</button>
</div>
<div style="display:flex; gap:8px; justify-content:center;">
<button class="btn-secondary" id="vault-btn">open vault</button>
<button class="btn-secondary" id="settings-btn">settings</button>
</div> </div>
</div> </div>
`; `;
const input = document.getElementById('passphrase-input') as HTMLInputElement; const input = document.getElementById('passphrase-input') as HTMLInputElement;
const unlockBtn = document.getElementById('unlock-btn') as HTMLButtonElement | null;
const submit = async () => {
const passphrase = input.value;
if (!passphrase) return;
setState({ loading: true, error: null });
const resp = await sendMessage({ type: 'unlock', passphrase });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items });
} else {
setState({ loading: false, error: listResp.error });
}
} else {
setState({ loading: false, error: resp.error });
}
};
if (input && !state.loading) { if (input && !state.loading) {
input.focus(); input.focus();
input.addEventListener('keydown', async (e) => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
if (e.key === 'Enter') {
const passphrase = input.value;
if (!passphrase) return;
setState({ loading: true, error: null });
const resp = await sendMessage({ type: 'unlock', passphrase });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items });
} else {
setState({ loading: false, error: listResp.error });
}
} else {
setState({ loading: false, error: resp.error });
}
}
});
} }
unlockBtn?.addEventListener('click', submit);
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
const settingsBtn = document.getElementById('settings-btn');
settingsBtn?.addEventListener('click', () => navigate('settings'));
} }

View File

@@ -6,7 +6,7 @@
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<title>Relicario</title> <title>Relicario</title>
</head> </head>
<body> <body class="surface-backdrop">
<div id="app"></div> <div id="app"></div>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>

View File

@@ -4,6 +4,7 @@
/// Navigation works by updating `currentState` and calling `render()`. /// Navigation works by updating `currentState` and calling `render()`.
import type { Request, Response } from '../shared/messages'; import type { Request, Response } from '../shared/messages';
import { lookupErrorCopy } from '../shared/error-copy';
import type { ItemId, ManifestEntry, Item } from '../shared/types'; import type { ItemId, ManifestEntry, Item } from '../shared/types';
import { registerHost } from '../shared/state'; import { registerHost } from '../shared/state';
import { renderUnlock } from './components/unlock'; import { renderUnlock } from './components/unlock';
@@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history';
import { teardown as teardownTrash } from './components/trash'; import { teardown as teardownTrash } from './components/trash';
import { teardown as teardownDevices } from './components/devices'; import { teardown as teardownDevices } from './components/devices';
import { teardown as teardownFieldHistory } from './components/field-history'; import { teardown as teardownFieldHistory } from './components/field-history';
import { applyColorScheme } from '../shared/color-scheme';
// --- Escape HTML to prevent XSS --- // --- Escape HTML to prevent XSS ---
export function escapeHtml(str: string): string { export function escapeHtml(str: string): string {
@@ -144,19 +146,8 @@ export function humanizeError(err: string): string {
if (/settings json:/i.test(err)) { if (/settings json:/i.test(err)) {
return 'Settings are in an invalid format — try reloading the extension.'; return 'Settings are in an invalid format — try reloading the extension.';
} }
if (/vault_locked/i.test(err)) { const copy = lookupErrorCopy(err);
return 'Vault is locked. Unlock and try again.'; return copy.body;
}
if (/origin_mismatch/i.test(err)) {
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
}
if (/unauthorized_sender/i.test(err)) {
return 'This action is not allowed from here.';
}
if (/tab_navigated|captured_tab_gone/i.test(err)) {
return 'The browser tab changed before the fill could complete — try again.';
}
return err;
} }
// --- Navigation --- // --- Navigation ---
@@ -225,6 +216,14 @@ function render(): void {
// --- Init --- // --- Init ---
async function init(): Promise<void> { async function init(): Promise<void> {
await applyColorScheme();
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && 'password_display_scheme' in changes) {
void applyColorScheme();
}
});
// Snapshot the active tab at popup-open — the fill path uses this // Snapshot the active tab at popup-open — the fill path uses this
// tabId/url pair so the SW can verify the tab hasn't navigated before // tabId/url pair so the SW can verify the tab hasn't navigated before
// forwarding credentials (audit M5 + TOCTOU close via expectedHost). // forwarding credentials (audit M5 + TOCTOU close via expectedHost).

View File

@@ -1,22 +1,35 @@
/* Relicario extension — terminal dark theme */ /* Relicario extension — terminal dark theme */
:root { :root {
/* Brand */ /* Patina gold (Phase 2B) */
--accent: #d2ab43; --gold-base: #a88a4a;
--accent-soft: rgba(210, 171, 67, 0.18); --gold-mid: #cdb47a;
--accent-strong: #aa812a; --gold-shadow: #5a3f12;
--gold-text: #c9a868;
--gold-soft: rgba(184, 149, 86, 0.14);
--gold-ring: rgba(184, 149, 86, 0.18);
--gold-stroke: #b89556;
--gold-hi-end: #dac8a0;
/* Brand alias (kept for backwards compatibility) */
--accent: var(--gold-base);
--accent-soft: var(--gold-soft);
--accent-strong: var(--gold-shadow);
/* Surfaces */ /* Surfaces */
--bg-page: #0d1117; --bg-page: #0a0e14;
--bg-pane: #161b22; --bg-pane: #11161e;
--bg-elevated: #21262d; --bg-elevated: #1c2330;
--bg-input: #161b22; --bg-card: rgba(22, 27, 34, 0.55);
--border-subtle: #30363d; --bg-input: #0a0e14;
--border-soft: rgba(255, 255, 255, 0.05);
--border-mid: #262d36;
--border-subtle: var(--border-mid);
/* Text */ /* Text */
--text: #c9d1d9; --text: #c9d1d9;
--text-muted: #8b949e; --text-muted: #8b949e;
--text-dim: #484f58; --text-dim: #6b7888;
/* Status */ /* Status */
--danger: #ab2b20; --danger: #ab2b20;
@@ -24,7 +37,11 @@
--success: #6cb37a; --success: #6cb37a;
/* Focus */ /* Focus */
--focus-ring: 0 0 0 2px rgba(210, 171, 67, 0.35); --focus-ring: 0 0 0 2px var(--gold-ring);
/* Password coloring (P1) */
--relicario-pwd-digit-color: #2563eb;
--relicario-pwd-symbol-color: #dc2626;
} }
* { * {
@@ -37,7 +54,7 @@ body {
width: 360px; width: 360px;
max-height: 500px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
background: #0d1117; background: var(--bg-page);
color: #c9d1d9; color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px; font-size: 13px;
@@ -62,7 +79,7 @@ body {
.brand { .brand {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #d2ab43; color: var(--gold-text);
letter-spacing: 1px; letter-spacing: 1px;
} }
@@ -407,6 +424,41 @@ textarea {
background: #aa812a; background: #aa812a;
} }
/* Setup wizard — Style C progress track */
.setup-progress-track {
display: flex;
gap: 4px;
width: 100%;
max-width: 560px;
margin: 8px auto 16px;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
}
.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active { background: var(--gold, #b8860b); }
.setup-progress-segment--pending { background: var(--border, #30363d); }
/* Setup wizard — Recovery QR banner */
.recovery-qr-banner {
padding: 12px 16px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--gold, #b8860b);
border-radius: 8px;
}
.recovery-qr-banner__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.recovery-qr-banner__actions {
display: flex;
gap: 8px;
}
/* Spinner */ /* Spinner */
.spinner { .spinner {
display: inline-block; display: inline-block;
@@ -1457,3 +1509,102 @@ textarea {
.f-notes--mono { .f-notes--mono {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important; font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
} }
/* Phase 2B: surface backdrop — subtle radial top-glow + grid texture.
Apply to body or a top-level wrapper. Children must sit above the ::before. */
.surface-backdrop {
position: relative;
background:
radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184, 149, 86, 0.05), transparent 65%),
linear-gradient(180deg, #11161e 0%, #0a0e14 100%);
}
.surface-backdrop::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px);
background-size: 18px 18px;
pointer-events: none;
z-index: 0;
}
.surface-backdrop > * {
position: relative;
z-index: 1;
}
/* Phase 2B: glass card. Translucent panel with backdrop blur for the
unlock card, setup step card, and form section panels. Falls back
gracefully on browsers without backdrop-filter (just stays translucent). */
.glass {
background: var(--bg-card);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--border-soft);
border-radius: 10px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 6px 18px rgba(0, 0, 0, 0.35);
}
/* Phase 2B: button hierarchy. Existing .btn class kept for backwards
compatibility; .btn-primary and .btn-secondary express clearer intent
and are used in updated views. */
.btn-primary {
background: var(--gold-base);
color: var(--bg-page);
border: none;
padding: 9px 14px;
font-size: 12px;
font-weight: 600;
border-radius: 6px;
font-family: inherit;
cursor: pointer;
letter-spacing: 0.3px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background-color 0.15s;
}
.btn-primary:hover { background: var(--gold-stroke); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn-secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-muted);
padding: 6px 12px;
font-size: 11px;
border-radius: 5px;
font-family: inherit;
cursor: pointer;
}
.btn-secondary:hover { border-color: rgba(255, 255, 255, 0.12); color: var(--text); }
.btn-secondary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
/* Password character-class coloring */
.pwd-digit { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }
.color-preview-swatch {
font-family: ui-monospace, monospace;
font-size: 1.1rem;
padding: 8px 12px;
border: 1px solid var(--border-mid);
border-radius: 4px;
margin-top: 8px;
background: var(--bg-input);
}

View File

@@ -1,14 +1,22 @@
/// Device management — reads/writes .relicario/devices.json /// Device management — reads/writes .relicario/devices.json and revoked.json
import type { GitHost } from './git-host'; import type { GitHost } from './git-host';
import type { Device } from '../shared/types'; import type { Device } from '../shared/types';
const DEVICES_PATH = '.relicario/devices.json'; const DEVICES_PATH = '.relicario/devices.json';
const REVOKED_PATH = '.relicario/revoked.json';
interface DevicesFile { interface DevicesFile {
devices: Device[]; devices: Device[];
} }
export interface RevokedEntry {
name: string;
public_key: string;
revoked_at: number; // unix timestamp
revoked_by: string; // name of device that performed the revocation
}
export async function readDevices(gitHost: GitHost): Promise<Device[]> { export async function readDevices(gitHost: GitHost): Promise<Device[]> {
try { try {
const raw = await gitHost.readFile(DEVICES_PATH); const raw = await gitHost.readFile(DEVICES_PATH);
@@ -30,6 +38,25 @@ export async function writeDevices(
await gitHost.writeFile(DEVICES_PATH, bytes, message); await gitHost.writeFile(DEVICES_PATH, bytes, message);
} }
export async function readRevoked(gitHost: GitHost): Promise<RevokedEntry[]> {
try {
const raw = await gitHost.readFile(REVOKED_PATH);
const text = new TextDecoder().decode(raw);
return JSON.parse(text) as RevokedEntry[];
} catch {
return [];
}
}
async function writeRevoked(
gitHost: GitHost,
revoked: RevokedEntry[],
message: string,
): Promise<void> {
const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2));
await gitHost.writeFile(REVOKED_PATH, bytes, message);
}
export async function addDevice( export async function addDevice(
gitHost: GitHost, gitHost: GitHost,
device: Device, device: Device,
@@ -45,11 +72,25 @@ export async function addDevice(
export async function revokeDevice( export async function revokeDevice(
gitHost: GitHost, gitHost: GitHost,
name: string, name: string,
revokedBy?: string,
): Promise<void> { ): Promise<void> {
const existing = await readDevices(gitHost); const existing = await readDevices(gitHost);
const filtered = existing.filter((d) => d.name !== name); const device = existing.find((d) => d.name === name);
if (filtered.length === existing.length) { if (!device) {
throw new Error(`device '${name}' not found`); throw new Error(`device '${name}' not found`);
} }
// Remove from devices.json
const filtered = existing.filter((d) => d.name !== name);
await writeDevices(gitHost, filtered, `device: revoke ${name}`); await writeDevices(gitHost, filtered, `device: revoke ${name}`);
// Add to revoked.json
const revoked = await readRevoked(gitHost);
revoked.push({
name,
public_key: device.public_key,
revoked_at: Math.floor(Date.now() / 1000),
revoked_by: revokedBy ?? 'unknown',
});
await writeRevoked(gitHost, revoked, `device: revoke ${name} (revoked log)`);
} }

View File

@@ -17,6 +17,7 @@ export class GiteaHost implements GitHost {
private baseUrl: string; private baseUrl: string;
private gitApiBase: string; private gitApiBase: string;
private commitsUrl: string; private commitsUrl: string;
private keysUrl: string;
private branch: string = 'main'; private branch: string = 'main';
private headers: Record<string, string>; private headers: Record<string, string>;
@@ -27,6 +28,7 @@ export class GiteaHost implements GitHost {
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`; this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`; this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`; this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
this.keysUrl = `${apiUrl}/repos/${repoPath}/keys`;
this.headers = { this.headers = {
'Authorization': `token ${apiToken}`, 'Authorization': `token ${apiToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -244,4 +246,31 @@ export class GiteaHost implements GitHost {
async deleteBlob(path: string, message: string): Promise<void> { async deleteBlob(path: string, message: string): Promise<void> {
return this.deleteFile(path, message); return this.deleteFile(path, message);
} }
/// Create a deploy key for this repo, returning its numeric ID.
async createDeployKey(title: string, publicKey: string): Promise<number> {
const resp = await fetch(this.keysUrl, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({ title, key: publicKey, read_only: false }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`createDeployKey: ${resp.status} ${text}`);
}
const json = await resp.json() as { id: number };
return json.id;
}
/// Delete a deploy key by numeric ID. Ignores 404 (already gone).
async deleteDeployKey(keyId: number): Promise<void> {
const resp = await fetch(`${this.keysUrl}/${keyId}`, {
method: 'DELETE',
headers: this.headers,
});
if (!resp.ok && resp.status !== 404) {
const text = await resp.text();
throw new Error(`deleteDeployKey: ${resp.status} ${text}`);
}
}
} }

View File

@@ -346,6 +346,12 @@ export async function handle(
return { ok: true, data: { devices: list } }; return { ok: true, data: { devices: list } };
} }
case 'list_revoked': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const revoked = await devices.readRevoked(state.gitHost);
return { ok: true, data: { revoked } };
}
case 'add_device': { case 'add_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const device = { const device = {
@@ -359,17 +365,15 @@ export async function handle(
case 'register_this_device': { case 'register_this_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const keypair = state.wasm.generate_device_keypair() as { // register_device keeps private keys internal — only public keys cross to JS
public_key_hex: string; const keys = state.wasm.register_device(msg.name) as {
private_key_base64: string; signing_public_key: string;
deploy_public_key: string;
}; };
await chrome.storage.local.set({ await chrome.storage.local.set({ device_name: msg.name });
device_name: msg.name,
device_private_key: keypair.private_key_base64,
});
await devices.addDevice(state.gitHost, { await devices.addDevice(state.gitHost, {
name: msg.name, name: msg.name,
public_key: keypair.public_key_hex, public_key: keys.signing_public_key,
added_at: Math.floor(Date.now() / 1000), added_at: Math.floor(Date.now() / 1000),
}); });
return { ok: true }; return { ok: true };
@@ -377,7 +381,9 @@ export async function handle(
case 'revoke_device': { case 'revoke_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
await devices.revokeDevice(state.gitHost, msg.name); const stored = await chrome.storage.local.get(['device_name']);
const revokedBy = stored.device_name as string | undefined;
await devices.revokeDevice(state.gitHost, msg.name, revokedBy);
return { ok: true }; return { ok: true };
} }
@@ -568,6 +574,26 @@ export async function handle(
} }
} }
case 'generate_recovery_qr': {
const handle = session.getCurrent();
if (!handle) return { ok: false, error: 'vault_locked' };
try {
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
return { ok: true, data: { svg } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'unwrap_recovery_qr': {
try {
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'import_lastpass_commit': { case 'import_lastpass_commit': {
const handle = session.getCurrent(); const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { finishSetup } from '../setup';
describe('finishSetup', () => {
beforeEach(() => {
(global as any).chrome = {
tabs: {
create: vi.fn(() => Promise.resolve({ id: 999 })),
getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
remove: vi.fn(() => Promise.resolve()),
},
runtime: {
getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
},
};
});
it('opens vault.html in a new tab', async () => {
await finishSetup();
expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
expect(chrome.tabs.create).toHaveBeenCalledWith({
url: 'chrome-extension://abc/vault.html',
});
});
it('closes the current setup tab after opening the vault tab', async () => {
await finishSetup();
expect(chrome.tabs.getCurrent).toHaveBeenCalled();
expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
});
it('still opens the vault tab even if closing the setup tab fails', async () => {
(chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
await expect(finishSetup()).resolves.not.toThrow();
expect(chrome.tabs.create).toHaveBeenCalled();
});
});

View File

@@ -16,6 +16,7 @@ import {
STRENGTH_LABELS, STRENGTH_LABELS,
entropyText, entropyText,
} from './setup-helpers'; } from './setup-helpers';
import { GLYPH_NEXT } from '../shared/glyphs';
import type { VaultConfig } from '../shared/types'; import type { VaultConfig } from '../shared/types';
import type { SessionHandle } from 'relicario-wasm'; import type { SessionHandle } from 'relicario-wasm';
@@ -92,6 +93,17 @@ const state: WizardState = {
deviceName: '', deviceName: '',
}; };
// --- Progress track ---
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
function renderProgressTrack(current: number): string {
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
}).join('')}</div>`;
}
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- // --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
/// Update just the meter DOM without a full re-render (so the input keeps /// Update just the meter DOM without a full re-render (so the input keeps
@@ -167,16 +179,7 @@ function render(): void {
const app = document.getElementById('app'); const app = document.getElementById('app');
if (!app) return; if (!app) return;
const progressHtml = ` const progressHtml = renderProgressTrack(state.step);
<div class="progress-bar">
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
</div>
`;
let stepHtml = ''; let stepHtml = '';
switch (state.step) { switch (state.step) {
@@ -189,12 +192,14 @@ function render(): void {
} }
app.innerHTML = ` app.innerHTML = `
<div class="pad" style="padding-top:12px;"> <div class="surface-backdrop" style="min-height:100vh;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;"> <div class="pad" style="padding-top:12px;">
<div class="brand" style="margin-bottom:4px;">Relicario vault setup</div> <img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
${progressHtml} <div class="brand" style="margin-bottom:4px;">Relicario vault setup</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''} ${progressHtml}
${stepHtml} ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
${stepHtml}
</div>
</div> </div>
`; `;
@@ -214,20 +219,22 @@ function renderStep0(): string {
const isNew = state.mode === 'new'; const isNew = state.mode === 'new';
const isAttach = state.mode === 'attach'; const isAttach = state.mode === 'attach';
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>set up Relicario</h3> <h3>set up Relicario</h3>
<p class="muted" style="margin-bottom:16px;"> <p class="muted" style="margin-bottom:16px;">
How are you using Relicario on this device? How are you using Relicario on this device?
</p> </p>
<div class="mode-cards"> <div class="mode-cards">
<button class="mode-card ${isNew ? 'active' : ''}" data-mode="new"> <button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div> <div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I'm setting up Relicario for the first time. This will create a fresh I'm setting up Relicario for the first time. This will create a fresh
encrypted vault on a new or empty git repository. encrypted vault on a new or empty git repository.
</p> </p>
</button> </button>
<button class="mode-card ${isAttach ? 'active' : ''}" data-mode="attach"> <button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div> <div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it I already have a vault on another device. Connect this browser to it
@@ -236,7 +243,7 @@ function renderStep0(): string {
</button> </button>
</div> </div>
<div class="form-actions" style="margin-top:24px;"> <div class="form-actions" style="margin-top:24px;">
<button class="btn btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next</button> <button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -267,7 +274,7 @@ function renderStep3Attach(): string {
const gateDisabled = state.attaching || !p || !hasImage; const gateDisabled = state.attaching || !p || !hasImage;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>attach this device</h3> <h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;"> <p class="muted" style="margin-bottom:12px;">
Use your existing passphrase and reference image to attach this browser Use your existing passphrase and reference image to attach this browser
@@ -430,7 +437,7 @@ function renderStep1(): string {
`; `;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>choose host</h3> <h3>choose host</h3>
<div class="form-group"> <div class="form-group">
<label class="label">host type</label> <label class="label">host type</label>
@@ -442,7 +449,7 @@ function renderStep1(): string {
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions} ${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
<div class="form-actions"> <div class="form-actions">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn">next</button> <button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -522,7 +529,7 @@ function renderStep2(): string {
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); !!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
const nextDisabled = !state.connectionTested || !probe || modeMismatch; const nextDisabled = !state.connectionTested || !probe || modeMismatch;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>configure connection</h3> <h3>configure connection</h3>
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}> <div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
<label class="label" for="host-url">host url</label> <label class="label" for="host-url">host url</label>
@@ -543,7 +550,7 @@ function renderStep2(): string {
${renderProbeBanner()} ${renderProbeBanner()}
<div class="form-actions" style="margin-top:12px;"> <div class="form-actions" style="margin-top:12px;">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next</button> <button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -643,7 +650,7 @@ function renderStep3New(): string {
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>create vault</h3> <h3>create vault</h3>
<div class="form-group"> <div class="form-group">
@@ -907,7 +914,7 @@ function renderStep4(): string {
const defaultName = state.deviceName || `${browser} on ${os}`; const defaultName = state.deviceName || `${browser} on ${os}`;
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>name this device</h3> <h3>name this device</h3>
<p class="muted" style="margin-bottom:12px;"> <p class="muted" style="margin-bottom:12px;">
This helps you identify which devices have access to your vault. This helps you identify which devices have access to your vault.
@@ -918,7 +925,7 @@ function renderStep4(): string {
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn" id="back-btn">back</button> <button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn">continue</button> <button class="btn-primary" id="next-btn">continue ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
`; `;
@@ -978,8 +985,24 @@ function renderStep5(): string {
const configJson = JSON.stringify(config, null, 2); const configJson = JSON.stringify(config, null, 2);
const isAttach = state.mode === 'attach'; const isAttach = state.mode === 'attach';
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>
` : '';
return ` return `
<div class="wizard-step"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box"> <div class="success-box">
<h3>${isAttach ? 'device verified' : 'vault created'}</h3> <h3>${isAttach ? 'device verified' : 'vault created'}</h3>
<p class="secondary"> <p class="secondary">
@@ -989,6 +1012,8 @@ function renderStep5(): string {
</p> </p>
</div> </div>
${qrBannerHtml}
${isAttach ? '' : ` ${isAttach ? '' : `
<div class="form-group"> <div class="form-group">
<label class="label">reference image</label> <label class="label">reference image</label>
@@ -1023,6 +1048,48 @@ function renderStep5(): string {
} }
function attachStep5(): void { function attachStep5(): void {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
if (!state.verifiedHandle) return;
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const { sendMessage } = await import('../shared/state');
const resp = await sendMessage({
type: 'generate_recovery_qr',
sessionHandle: state.verifiedHandle.value,
passphrase: state.passphrase,
} as any) as any;
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
◉ Recovery QR generated — save or print this now.
</p>
<div style="margin-top:8px;">
<button class="btn" id="setup-qr-done">Done</button>
</div>
`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => { document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return; if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
@@ -1049,12 +1116,12 @@ function attachStep5(): void {
try { try {
const w = await loadWasm(); const w = await loadWasm();
const keypair = w.generate_device_keypair(); // register_device keeps private keys internal — only public keys returned
const keypair = w.register_device(state.deviceName);
// 1) Save private key + name locally. // 1) Save device name locally (private keys stay in WASM memory).
await chrome.storage.local.set({ await chrome.storage.local.set({
device_name: state.deviceName, device_name: state.deviceName,
device_private_key: keypair.private_key_base64,
}); });
// 2) Save vault config + reference image to extension storage. // 2) Save vault config + reference image to extension storage.
@@ -1086,7 +1153,7 @@ function attachStep5(): void {
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await addDevice(host, { await addDevice(host, {
name: state.deviceName, name: state.deviceName,
public_key: keypair.public_key_hex, public_key: keypair.signing_public_key,
added_at: Math.floor(Date.now() / 1000), added_at: Math.floor(Date.now() / 1000),
}); });
@@ -1098,6 +1165,7 @@ function attachStep5(): void {
state.configPushed = true; state.configPushed = true;
render(); render();
void finishSetup();
} catch (err: unknown) { } catch (err: unknown) {
console.error('[relicario setup] register device failed:', err); console.error('[relicario setup] register device failed:', err);
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
@@ -1128,6 +1196,23 @@ function attachStep5(): void {
}); });
} }
// --- Completion handoff ---
/// Open the fullscreen vault tab and best-effort close the setup tab.
export async function finishSetup(): Promise<void> {
const vaultUrl = chrome.runtime.getURL('vault.html');
await chrome.tabs.create({ url: vaultUrl });
try {
const current = await chrome.tabs.getCurrent();
if (current?.id !== undefined) {
await chrome.tabs.remove(current.id);
}
} catch {
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
// The vault tab is open — that's the user-visible success.
}
}
// --- Boot --- // --- Boot ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../color-scheme';
function mockChromeStorage(initial: any = {}) {
const store = { ...initial };
(global as any).chrome = {
storage: {
sync: {
get: vi.fn((key: string) => Promise.resolve(
key in store ? { [key]: store[key] } : {})),
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
},
};
return store;
}
describe('color-scheme storage', () => {
beforeEach(() => {
// happy-dom provides document globally; reset inline styles between tests
document.documentElement.removeAttribute('style');
});
it('load returns defaults when storage is empty', async () => {
mockChromeStorage();
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
});
it('load returns stored values when present', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
});
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe('#123456');
expect(scheme.symbol_color).toBe('#abcdef');
});
it('save round-trips', async () => {
mockChromeStorage();
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
const scheme = await loadColorScheme();
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
});
it('reset removes the storage key', async () => {
const store = mockChromeStorage({
password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
});
await resetColorScheme();
expect(store.password_display_scheme).toBeUndefined();
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
});
it('apply sets CSS custom properties on document.documentElement', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
});
await applyColorScheme();
const root = document.documentElement.style;
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
});
it('save rejects malformed hex values', async () => {
mockChromeStorage();
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
.rejects.toThrow();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';
const repoRoot = resolve(__dirname, '../../../..');
function discoverCodes(): Set<string> {
const out = execSync(
`grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
--include="*.ts" --exclude-dir=__tests__`,
{ cwd: repoRoot, encoding: 'utf-8' },
);
const codes = new Set<string>();
for (const line of out.split('\n')) {
const m = line.match(/error: '([^']+)'/);
if (m) codes.add(m[1]);
}
return codes;
}
describe('ERROR_COPY', () => {
it('contains an entry for every error code returned by the service worker', () => {
const discovered = discoverCodes();
expect(discovered.size).toBeGreaterThan(0);
const missing: string[] = [];
for (const code of discovered) {
if (!ERROR_COPY[code]) missing.push(code);
}
expect(missing).toEqual([]);
});
it('lookupErrorCopy returns the mapped entry for known codes', () => {
const copy = lookupErrorCopy('vault_locked');
expect(copy.title).toBe('Vault locked');
expect(copy.body).toMatch(/unlock/i);
});
it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
const copy = lookupErrorCopy('made_up_code_xyz');
expect(copy.title).toBe('Something went wrong');
expect(copy.body).toContain('made_up_code_xyz');
});
});

View File

@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import * as glyphs from '../glyphs'; import * as glyphs from '../glyphs';
import {
GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB,
GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS,
GLYPH_LOCK, GLYPH_NEXT,
} from '../glyphs';
describe('glyphs', () => { describe('glyphs', () => {
it('exports the documented glyph constants', () => { it('exports the documented glyph constants', () => {
@@ -19,3 +24,20 @@ describe('glyphs', () => {
expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>'); expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
}); });
}); });
describe('glyph constants', () => {
it('uses single unicode codepoints (no emoji multi-codepoint)', () => {
const all = [
GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB,
GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS,
GLYPH_LOCK, GLYPH_NEXT,
];
for (const g of all) {
expect([...g].length).toBe(1);
}
});
it('GLYPH_NEXT is the small right triangle (U+25B8)', () => {
expect(GLYPH_NEXT).toBe('▸');
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
describe('colorizePassword', () => {
function classes(frag: DocumentFragment): string[] {
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
}
function texts(frag: DocumentFragment): string[] {
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
}
it('returns empty fragment for empty input', () => {
const frag = colorizePassword('');
expect(frag.childNodes.length).toBe(0);
});
it('classifies a mixed-class run', () => {
const frag = colorizePassword('aB3$xY');
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
});
it('all-letters produces a single letter span', () => {
const frag = colorizePassword('passwd');
expect(classes(frag)).toEqual([PWD_LETTER]);
expect(texts(frag)).toEqual(['passwd']);
});
it('all-digits produces a single digit span', () => {
const frag = colorizePassword('123456');
expect(classes(frag)).toEqual([PWD_DIGIT]);
expect(texts(frag)).toEqual(['123456']);
});
it('all-symbols produces a single symbol span', () => {
const frag = colorizePassword('!@#$%^');
expect(classes(frag)).toEqual([PWD_SYMBOL]);
expect(texts(frag)).toEqual(['!@#$%^']);
});
it('classifies unicode letters as letters', () => {
const frag = colorizePassword('áñü');
expect(classes(frag)).toEqual([PWD_LETTER]);
});
it('classifies whitespace as symbol', () => {
const frag = colorizePassword('a b');
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
expect(texts(frag)).toEqual(['a', ' ', 'b']);
});
it('representative password snapshot: aB3$xY7&_!', () => {
const frag = colorizePassword('aB3$xY7&_!');
expect(classes(frag)).toEqual([
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
]);
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
});
});

View File

@@ -0,0 +1,48 @@
export const DEFAULT_DIGIT_COLOR = '#2563eb';
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
const STORAGE_KEY = 'password_display_scheme';
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
export interface ColorScheme {
digit_color: string;
symbol_color: string;
}
export const DEFAULT_SCHEME: ColorScheme = {
digit_color: DEFAULT_DIGIT_COLOR,
symbol_color: DEFAULT_SYMBOL_COLOR,
};
function isValid(s: ColorScheme): boolean {
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
}
export async function loadColorScheme(): Promise<ColorScheme> {
const result = await chrome.storage.sync.get(STORAGE_KEY);
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
if (!stored) return { ...DEFAULT_SCHEME };
return {
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
? stored.digit_color : DEFAULT_DIGIT_COLOR,
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
};
}
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
if (!isValid(scheme)) {
throw new Error('Invalid color values; expected #rrggbb hex strings.');
}
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
}
export async function resetColorScheme(): Promise<void> {
await chrome.storage.sync.remove(STORAGE_KEY);
}
export async function applyColorScheme(): Promise<void> {
const scheme = await loadColorScheme();
const root = document.documentElement.style;
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
}

View File

@@ -0,0 +1,102 @@
export interface ErrorCta {
label: string;
action?: 'unlock' | 'reload_extension' | 'open_setup';
}
export interface ErrorCopy {
title: string;
body: string;
cta?: ErrorCta;
}
const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };
export const ERROR_COPY: Record<string, ErrorCopy> = {
vault_locked: {
title: 'Vault locked',
body: 'Unlock your vault to continue.',
cta: UNLOCK_CTA,
},
unauthorized_sender: {
title: 'Action not allowed',
body: 'This action is not allowed from here.',
},
unknown_message_type: {
title: 'Internal error',
body: 'The extension received an unknown request — try reloading.',
cta: { label: 'Reload extension', action: 'reload_extension' },
},
origin_mismatch: {
title: 'Wrong site',
body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
},
not_a_login: {
title: 'Not a login',
body: 'That item does not have a username and password to fill.',
},
no_totp: {
title: 'No 2FA on this item',
body: 'This item does not have a TOTP secret configured.',
},
invalid_sender_url: {
title: 'Cannot read tab URL',
body: 'The current tab has no recognizable URL — try reloading the page.',
},
tab_navigated: {
title: 'Tab changed',
body: 'The browser tab changed before the action could complete — try again.',
},
captured_tab_gone: {
title: 'Tab is gone',
body: 'The browser tab closed before the action could complete — try again.',
},
item_not_found: {
title: 'Item not found',
body: 'That item is no longer in the vault — it may have been deleted from another device.',
},
attachment_not_found: {
title: 'Attachment missing',
body: 'The attachment is referenced in the item but is not present in the vault.',
},
upload_failed: {
title: 'Upload failed',
body: 'Could not upload the attachment — check your connection and try again.',
},
download_failed: {
title: 'Download failed',
body: 'Could not download the attachment — check your connection and try again.',
},
'invalid base32 secret': {
title: 'Invalid secret',
body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
},
'no items to import': {
title: 'Nothing to import',
body: 'The CSV did not contain any importable items.',
},
'no reference image stored locally': {
title: 'No reference image',
body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
},
'remote already contains a Relicario vault': {
title: 'Vault already exists',
body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
},
'Extension not configured. Run setup first.': {
title: 'Extension not configured',
body: 'Run setup before using this action.',
cta: { label: 'Open setup', action: 'open_setup' },
},
'Reference image not set. Run setup first.': {
title: 'Reference image missing',
body: 'Run setup to save your reference image.',
cta: { label: 'Open setup', action: 'open_setup' },
},
};
export function lookupErrorCopy(code: string): ErrorCopy {
return ERROR_COPY[code] ?? {
title: 'Something went wrong',
body: code,
};
}

View File

@@ -16,6 +16,7 @@ export const GLYPH_TRASH = '▦'; // sidebar trash nav
export const GLYPH_DEVICES = '⌬'; // sidebar devices nav export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_LOCK = '⏻'; // sidebar lock nav
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
/// Inline HTML snippet for the required-field pill. Use after a label's text: /// Inline HTML snippet for the required-field pill. Use after a label's text:
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>` /// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`

View File

@@ -41,6 +41,7 @@ export type PopupMessage =
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer } | { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
| { type: 'download_attachment'; itemId: string; attachmentId: string } | { type: 'download_attachment'; itemId: string; attachmentId: string }
| { type: 'list_devices' } | { type: 'list_devices' }
| { type: 'list_revoked' }
| { type: 'add_device'; name: string; public_key: string } | { type: 'add_device'; name: string; public_key: string }
| { type: 'register_this_device'; name: string } | { type: 'register_this_device'; name: string }
| { type: 'revoke_device'; name: string } | { type: 'revoke_device'; name: string }
@@ -60,7 +61,9 @@ export type PopupMessage =
} }
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer } | { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
| { type: 'import_lastpass_commit'; items: Item[] } | { type: 'import_lastpass_commit'; items: Item[] }
| { type: 'preview_totp_from_secret'; secret_b32: string }; | { type: 'preview_totp_from_secret'; secret_b32: string }
| { type: 'generate_recovery_qr'; passphrase: string }
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
// --- Messages a content script may send --- // --- Messages a content script may send ---
@@ -139,6 +142,10 @@ export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
data: { devices: Device[] }; data: { devices: Device[] };
} }
export interface ListRevokedResponse extends Extract<Response, { ok: true }> {
data: { revoked: Array<{ name: string; public_key: string; revoked_at: number; revoked_by: string }> };
}
export interface ListTrashedResponse extends Extract<Response, { ok: true }> { export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
data: { items: Array<[ItemId, ManifestEntry]> }; data: { items: Array<[ItemId, ManifestEntry]> };
} }
@@ -161,13 +168,14 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'ack_autofill_origin', 'get_settings', 'update_settings', 'ack_autofill_origin', 'get_settings', 'update_settings',
'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'get_vault_settings', 'update_vault_settings', 'get_blacklist',
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment', 'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
'list_devices', 'add_device', 'register_this_device', 'revoke_device', 'list_devices', 'list_revoked', 'add_device', 'register_this_device', 'revoke_device',
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
'get_field_history', 'get_field_history',
'get_session_config', 'update_session_config', 'get_session_config', 'update_session_config',
'export_backup', 'restore_backup', 'export_backup', 'restore_backup',
'parse_lastpass_csv', 'import_lastpass_commit', 'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret', 'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
] as PopupMessage['type'][]); ] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> { export interface ExportBackupResponse extends Extract<Response, { ok: true }> {

View File

@@ -0,0 +1,35 @@
export const PWD_DIGIT = 'pwd-digit';
export const PWD_SYMBOL = 'pwd-symbol';
export const PWD_LETTER = 'pwd-letter';
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
function classify(ch: string): Class {
if (/^\d$/.test(ch)) return PWD_DIGIT;
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
return PWD_SYMBOL;
}
export function colorizePassword(text: string): DocumentFragment {
const frag = document.createDocumentFragment();
if (text.length === 0) return frag;
const codepoints = Array.from(text);
let runStart = 0;
let runClass = classify(codepoints[0]);
for (let i = 1; i <= codepoints.length; i++) {
const c = i < codepoints.length ? classify(codepoints[i]) : null;
if (c !== runClass) {
const span = document.createElement('span');
span.className = runClass;
span.textContent = codepoints.slice(runStart, i).join('');
frag.appendChild(span);
if (c !== null) {
runStart = i;
runClass = c;
}
}
}
return frag;
}

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