49 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
73 changed files with 12587 additions and 443 deletions

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,6 +27,7 @@ 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"] }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2"

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,12 @@ 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,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -312,13 +314,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 +355,54 @@ 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,
}
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
@@ -385,7 +428,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 +435,7 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
Commands::Rate { passphrase } => cmd_rate(passphrase), Commands::Rate { passphrase } => cmd_rate(passphrase),
Commands::Device { action } => cmd_device(action),
} }
} }
@@ -414,11 +457,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 +515,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 +540,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 +550,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 +570,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 +586,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 +633,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 +645,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 +861,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 +926,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 +1000,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 +1127,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 +1137,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 +1180,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 +1397,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 +1415,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 +1495,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 +1517,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 +1645,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 +1668,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 +1694,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 +1720,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 +1908,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 +1962,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 +1975,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 +2047,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 +2221,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 +2233,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 +2264,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 +2309,254 @@ 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(())
}
}
}

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

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

@@ -408,7 +408,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,12 @@ 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,
} }
/// 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,9 @@ 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};

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,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,6 +5,7 @@
//! 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::*;
@@ -206,26 +207,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 +335,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,

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. │
@@ -217,21 +221,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. │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```

92
docs/SECURITY.md Normal file
View File

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

@@ -177,8 +177,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 |

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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,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

@@ -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,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

@@ -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

@@ -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

@@ -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>';

View File

@@ -235,7 +235,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,58 +267,86 @@ 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">
<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>
<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>
<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>
<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="form-group">
<div class="notes-with-toggle"> <div class="notes-with-toggle">
@@ -317,7 +358,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
<div class="form-actions"> <div class="form-actions" ${externalActions ? 'hidden' : ''}>
<button class="btn" id="cancel-btn">cancel</button> <button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button> <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div> </div>
@@ -433,7 +474,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

@@ -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,7 @@
--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);
} }
* { * {
@@ -37,7 +50,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 +75,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;
} }
@@ -1457,3 +1470,87 @@ 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; }

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 };
} }

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';
@@ -189,12 +190,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 +217,20 @@ 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">
<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">
<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 +239,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 +270,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 +433,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 +445,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 +525,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 +546,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 +646,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 +910,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 +921,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>
`; `;
@@ -979,7 +982,7 @@ function renderStep5(): string {
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;">
<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">
@@ -1049,12 +1052,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 +1089,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),
}); });

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

@@ -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 }
@@ -139,6 +140,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,7 +166,7 @@ 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',

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
describe('fullscreen form dirty subtitle', () => {
const vaultSrc = fs.readFileSync(
path.resolve(__dirname, '../vault.ts'),
'utf-8',
);
it('contains renderFormWrapped function', () => {
expect(vaultSrc).toContain('function renderFormWrapped');
});
it('starts pristine: renders "no changes" subtitle', () => {
expect(vaultSrc).toContain("'no changes'");
});
it('switches to dirty on first input event', () => {
expect(vaultSrc).toContain("'unsaved · esc to cancel'");
});
it('listens on input and change events on the scroll element', () => {
expect(vaultSrc).toContain("scrollEl.addEventListener('input', markDirty, true)");
expect(vaultSrc).toContain("scrollEl.addEventListener('change', markDirty, true)");
});
it('marks clean on save', () => {
expect(vaultSrc).toContain('markClean()');
});
it('contains platform-aware SAVE_HINT', () => {
expect(vaultSrc).toContain('SAVE_HINT');
expect(vaultSrc).toContain('⌘+S to save');
expect(vaultSrc).toContain('Ctrl+S to save');
});
it('renders fullscreen-form-header element', () => {
expect(vaultSrc).toContain('fullscreen-form-header');
});
it('renders form-dirty-sub element', () => {
expect(vaultSrc).toContain('form-dirty-sub');
});
});

View File

@@ -1,22 +1,35 @@
/* Relicario vault — terminal dark theme (tab layout) */ /* Relicario vault — terminal dark theme (tab layout) */
: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,7 @@
--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);
} }
* { * {
@@ -34,7 +47,7 @@
} }
body { body {
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 +75,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;
} }
@@ -1487,3 +1500,167 @@ 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);
}
/* Phase 2B: two-column form grid for fullscreen login */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 960px;
margin: 0 auto;
}
@media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; }
}
.form-col {
padding: 14px 16px;
}
.col-header {
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 500;
color: var(--text-muted);
font-size: 10px;
border-bottom: 1px solid var(--border-mid);
padding-bottom: 6px;
margin-bottom: 12px;
}
/* Phase 2B: fullscreen form header */
.fullscreen-form-header {
padding: 14px 24px;
border-bottom: 1px solid var(--border-mid);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.fullscreen-form-header .title {
font-size: 16px;
font-weight: 500;
color: var(--text);
}
.fullscreen-form-header .sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.fullscreen-form-header .hint {
font-size: 11px;
color: var(--text-dim);
}
/* Phase 2B: sticky save bar + scrollable form pane */
.form-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.form-scroll {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.sticky-save-bar {
position: sticky;
bottom: 0;
background: rgba(17, 22, 30, 0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border-top: 1px solid var(--border-mid);
padding: 12px 24px;
display: flex;
justify-content: flex-end;
gap: 8px;
z-index: 10;
}
.sticky-save-bar::before {
content: '';
position: absolute;
top: -24px;
left: 0;
right: 0;
height: 24px;
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
pointer-events: none;
}

View File

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

View File

@@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise<void> {
} }
} }
// ---------------------------------------------------------------------------
// Platform-aware save hint
// ---------------------------------------------------------------------------
const isMac = navigator.platform.toLowerCase().includes('mac');
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
// ---------------------------------------------------------------------------
// Fullscreen form wrapper — sticky save bar + scrollable content + header
// ---------------------------------------------------------------------------
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
const typeLabel = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
const wrapper = document.createElement('div');
wrapper.className = 'form-pane';
wrapper.innerHTML = `
<div class="fullscreen-form-header">
<div>
<div class="title">${titleText}</div>
<div class="sub" id="form-dirty-sub">no changes</div>
</div>
<div class="hint">${SAVE_HINT}</div>
</div>
<div class="form-scroll" id="form-scroll"></div>
<div class="sticky-save-bar">
<button class="btn-secondary" id="form-cancel">cancel</button>
<button class="btn-primary" id="form-save">save</button>
</div>
`;
// Remove pane padding so form-pane can fill height cleanly
app.style.padding = '0';
app.style.overflow = 'hidden';
app.replaceChildren(wrapper);
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
renderItemForm(scrollEl, mode);
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
let isDirty = false;
const markDirty = () => {
if (isDirty) return;
isDirty = true;
subEl.textContent = 'unsaved · esc to cancel';
};
const markClean = () => {
isDirty = false;
subEl.textContent = 'no changes';
};
scrollEl.addEventListener('input', markDirty, true);
scrollEl.addEventListener('change', markDirty, true);
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
markClean();
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
});
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
markClean();
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
});
}
export const __test__ = { renderFormWrapped };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pane rendering — delegates to shared popup components // Pane rendering — delegates to shared popup components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -453,10 +518,16 @@ function renderPane(): void {
// set by the type-selection click handler (which calls setState → // set by the type-selection click handler (which calls setState →
// renderPane before the URL hash has been updated to include the type). // renderPane before the URL hash has been updated to include the type).
state.newType = (route.type as ItemType) ?? state.newType ?? null; state.newType = (route.type as ItemType) ?? state.newType ?? null;
renderItemForm(pane, 'add'); // Use the form wrapper (sticky bar + header) when a type is already chosen.
// Without a type the type-selection screen renders — no sticky bar needed.
if (state.newType) {
renderFormWrapped(pane, 'add');
} else {
renderItemForm(pane, 'add');
}
break; break;
case 'edit': case 'edit':
renderItemForm(pane, 'edit'); renderFormWrapped(pane, 'edit');
break; break;
case 'trash': case 'trash':
renderTrash(pane); renderTrash(pane);

View File

@@ -61,7 +61,22 @@ declare module 'relicario-wasm' {
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
export function generate_device_keypair(): { public_key_hex: string; private_key_base64: string }; export function register_device(name: string): {
signing_public_key: string;
deploy_public_key: string;
};
export function sign_for_git(data: Uint8Array): {
signature: string;
};
export function get_device_info(): {
name: string;
signing_public_key: string;
deploy_public_key: string;
} | null;
export function clear_device(): void;
export function get_field_history(item_json: string): unknown; export function get_field_history(item_json: string): unknown;
export default function init(module_or_path?: unknown): Promise<void>; export default function init(module_or_path?: unknown): Promise<void>;