# 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=` 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.