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>
14 KiB
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
- Restore device-auth integrity: only registered, non-revoked signing keys can push to the vault repo.
- Close two minor hardening gaps surfaced during the security review.
- Fix two user-visible bugs (strength-meter desync after generate, raw error codes in the fullscreen tab).
- Deliver four UX improvements that are polish-shaped, not feature-shaped.
- 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 revokeis run — therevoked.jsonwrite is cosmetic.
Fix. Implement the verification logic per the design spec
(docs/superpowers/specs/2026-05-02-device-authentication-design.md:284-300):
- Build a temporary allowed-signers file from
devices.jsonentries loaded at the commit. Pass it togit verify-commitviaGIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile GIT_CONFIG_VALUE_0=<tmpfile>so we don't mutate global git config. - Parse the signing-key fingerprint out of
git verify-commit --rawoutput (the fingerprint appears on theGood "git" signature for … with … key SHA256:…line for SSH). - Reject if the fingerprint is not in
devices.jsonat the commit. - Reject if the fingerprint is in
revoked.jsonANDcommit_timestamp >= revoked_at. The historical-commit case (timestamp <revoked_at) is allowed so old commits survive revocation. - Use the commit's committer date (
GIT_COMMITTER_DATE, accessible viagit show -s --format=%ct) — not the current wall clock and not the author date — ascommit_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_archivesize 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.mdand the relevant--helptext. - Move
RELICARIO_NO_GROUPS_CACHEundercfg(debug_assertions)— it's a dev tool, not a user knob. - Leave
RELICARIO_IMAGEandRELICARIO_GITEA_URLas user-facing config; audit each call site to confirm no untrusted-input-flows-into-path cases.
Acceptance.
docs/SECURITY.mdlists everyRELICARIO_*env var with purpose and trust assumption.RELICARIO_NO_GROUPS_CACHEis no-op incargo 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
inputevent is dispatched and meter callsscheduleRatewith 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_lockedshown 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.
- Build a single
ERROR_COPYmap keyed by error code, returning{ title: string; body: string; cta?: { label: string; action: () => void } }. Place atextension/src/shared/error-copy.ts. - Audit call sites in
extension/src/service-worker/router/for all error codes; ensure each has a mapping. - Replace the regex in
popup.ts:147and the raw-error rendering in the fullscreen tab withlookupErrorCopy(code). - For
vault_lockedspecifically: the CTA is "Unlock vault" → opens the popup unlock view (or in the fullscreen tab, the inline unlock form).
Acceptance.
- No raw
snake_casestrings render in any UI surface for any error return path. Verified by grep + manual trigger of each error code. vault_lockedin the fullscreen tab shows friendly copy + an "Unlock vault" button that works.- Vitest: enumerate every distinct
error: '...'string literal found inextension/src/service-worker/router/via grep, assert each is a key inERROR_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:
- Open the fullscreen vault tab via
chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') }). - 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.createis 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
- Plan A and Plan B can run in parallel (no shared files).
- Inside Plan A: S1 first (unblocks the security claim), then S2, then S3, then C1.
- 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).
- Both plans merge to main; tag
v0.5.0after both PRs are green.
Risks
- S1 SSH allowed-signers parsing.
git verify-commit --rawoutput 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 mainfilter and prompt before each-D. The no-destructive-without-asking project rule applies.