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>
This commit is contained in:
adlee-was-taken
2026-05-02 15:45:57 -04:00
parent 5da1e520e3
commit 57237af39e

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.