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>
341 lines
14 KiB
Markdown
341 lines
14 KiB
Markdown
# 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.
|