Plan A (security & docs polish, S): SessionHandle impl Drop + JS .free() audit + recovery_qr.rs documentation + relay launcher dev-c expansion. Independent of B/C; ships first. Plan B (CLI restructure, M-L): split cli/main.rs (2641 LOC) into commands/ folder + prompt.rs + parse.rs; helpers::git_run captures stderr; Vault:: after_manifest_change centralizes the groups-cache discipline; canonical ParamsFile; batched purge; migrate parse_month_year/base32_decode_lenient/ guess_mime to relicario-core with WASM re-exports. Plan C (extension restructure, L): typed StateHost (precondition); extract service-worker/storage.ts; setup.ts SW migration via create_vault/ attach_vault messages + step-registry pattern; vault.ts split into shell/sidebar/list/drawer/form-wrapper with vault_locked channel unified through shared/state.ts; P2 cluster (timer reset, gitHost clear, teardown helper, allSettled, MutationObserver debounce); get_vault_status closes the relicario status parity gap. Cross-boundary cites verified: Plan B Phase 8 WASM exports are the seam Plan C consumes (deferred to a future plan); Plan A owns the .free() swallow removal that Plan C respects without redoing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
23 KiB
Security & Docs Polish — Design
Date: 2026-05-04
Status: Proposed
Source: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.1, P1.7, P1.8)
Effort estimate: S (whole-PR; phase totals below)
Summary
This is the one-day security and documentation PR that ships first in the v0.5.x architecture-followup train, independent of Plan B (CLI restructure) and Plan C (extension restructure). It closes the single defense-in-depth crypto gap the review flagged (SessionHandle has no impl Drop, so wasm-bindgen's .free() is a cleanup no-op while the master key sits in WASM linear memory), removes the JS-side error swallow that was masking that fact, brings the one undocumented security-critical core file (recovery_qr.rs) up to the documentation density of crypto.rs / imgsecret.rs / backup.rs / tar_safe.rs, and confirms the relay test/launcher polish items. Together these are mechanical, low-risk changes that fix the only finding in the review where "what the code looks like it does" diverges dangerously from "what it actually does" — which is exactly the wall a tinkerer learning Rust would hit hardest.
Findings addressed
- P1.1 —
SessionHandlehas noimpl Drop;.free()is a cleanup no-op — DEV-B (Rust headline) + DEV-C (JS partner finding). Files:crates/relicario-wasm/src/lib.rs:15-23,crates/relicario-wasm/src/session.rs:1-58,extension/src/service-worker/session.ts:26. - Partner JS swallow — DEV-C P2 in service-worker. File:
extension/src/service-worker/session.ts:26(thetry { current.free(); } catch { /* already freed */ }block). - P1.7 —
recovery_qr.rsis undocumented relative to the rest of core — DEV-A. File:crates/relicario-core/src/recovery_qr.rs:1-130. - P1.8 —
tools/relay/queue.test.tsassertion update — DEV-C. File:tools/relay/queue.test.ts:54. Confirmed during planning: already fixed in061facd— line 54 now readsassert.ok(isRole("dev-c"));and the negative caseassert.ok(!isRole("dev-d"));is on line 55. No code work remains; the plan tracks this as a verification step. - Launcher follow-up —
tools/relay/start.shstill references three roles — DEV-C P2. File:tools/relay/start.sh:30-86. Confirmed during planning: not yet fixed. Bothlaunch_tmuxandlaunch_kittyopen only PM, Dev-A, Dev-B (no Dev-C); the manual-mode banner still says "Open 3 new terminals"; the post-launch summary lines (:61, :81) print "3 windows (PM, Dev-A, Dev-B)"; no*-dev-c-prompt.mdis discovered or used. This phase is real.
Approach
The PR is mechanical and uniform across four phases:
- Adds one
impl Drop for SessionHandleblock incrates/relicario-wasm/src/lib.rs, one Rust test that exercises construct → drop → registry-empty, one module-level//!doc-block inrecovery_qr.rs, one ASCII layout diagram next to the magic constants there, four per-function doc-comments on the public API there, and oneDEV_C_PROMPTdiscovery line + a fourth window in each launcher mode ofstart.sh. - Removes the
try { current.free(); } catch { /* already freed */ }swallow atextension/src/service-worker/session.ts:26. - Verifies every
.free()callsite underextension/src/(the audit deliverable for P1.1's JS side; today the audit itself is small — only one match — but the deliverable is the recorded grep + a check that every code path that holds aSessionHandlecallswasm.lock(handle)beforefree()). - Confirms in passing that
tools/relay/queue.test.ts:54already matches the new role union (it does — committed in061facd).
No public API changes on the Rust side beyond the Drop impl (which is observably equivalent to "calling lock(handle) was previously the only way to clean up"). No JS public API changes. No file moves. No new dependencies (wasm-bindgen-test is already in [dev-dependencies] for the wasm crate).
Implementation phases
Phase 1 — Rust-side impl Drop for SessionHandle + test
- Goal: make wasm-bindgen's auto-generated
.free()actually clear the master key from WASM linear memory, regardless of whether JS also calledlock(handle). - Changes:
crates/relicario-wasm/src/lib.rs— addimpl Drop for SessionHandle { fn drop(&mut self) { let _ = session::remove(self.0); } }immediately after the existing#[wasm_bindgen] impl SessionHandle { ... value() ... }block (around line 23). Update the doc-comment onSessionHandle(line 15) to document the contract: "Dropping (or.free()from JS) removes the entry from the session registry and zeroizes the wrapped key/image_secret.lock(handle)remains available as the explicit early-cleanup path;Dropis the safety net."crates/relicario-wasm/src/session.rs— no functional change required, but add a one-line module-level note abovepub fn remove(...)explicitly stating thatDrop for SessionHandleis the second caller of this function alongsidelock(), so that a future reader removinglock()doesn't accidentally orphan the cleanup path.
- Tests:
- Add a
#[wasm_bindgen_test](thewasm-bindgen-testcrate is already in[dev-dependencies], line 25 ofCargo.toml) undercrates/relicario-wasm/tests/session_drop.rs. The test constructs aSessionHandle(via the publicunlockpath with synthetic JPEG + tinyKdfParamsfor fast key derivation, mirroring the existing nativesession_tests::manifest_round_trip_via_handleshape), records the underlying handle id viahandle.value(), drops the handle, then assertssession::with(id, |_| ()).is_none(). Thesession::withsymbol must be re-exported fromtests/scope; if it isn't, exposepub fn with_for_tests(handle: u32) -> boolon thesessionmodule guarded by#[cfg(test)]so the test does not need to reach into thread-locals. - As a fallback / belt-and-suspenders, add a parallel native
#[test]under the existingmod session_testsinlib.rsthat constructs aSessionHandle(session::insert(...)), drops it explicitly withdrop(handle), then assertssession::with(h, |_| ()).is_none(). Native tests run on everycargo test; the wasm-bindgen-test runs only when someone invokeswasm-pack test, so the native variant is the one that catches regressions in CI.
- Add a
- Effort: S
- Depends on: none
Phase 2 — JS-side .free() audit + remove the swallow
- Goal: make the JS side stop hiding crypto-state-transition errors, and prove (via a recorded audit) that there are no other
.free()callsites onSessionHandle(or onEncryptedAttachment/TotpCode) that bypasswasm.lock(handle)first. - Changes:
extension/src/service-worker/session.ts:24-28— replace the body ofclearCurrent()so thatcurrent.free()is called unconditionally (notry/catch) andcurrentis set tonullafterwards. The doc-block at the top of the file (lines 1-9) gets one extra paragraph noting that withimpl Dropon the Rust side (Phase 1),free()now removes the registry entry —lock(handle)becomes a redundant early-cleanup option, but the SW currently does not call it. Document whether to keepclearCurrent()minimal (justfree()) or add an explicitwasm.lock(current.value)immediately beforefree()for symmetry with the CLI's session lifecycle. Recommended: justfree()post-Phase-1 — callinglockfirst is now belt-and-suspenders that adds a redundant HashMap remove. Document the rationale in the comment.- Audit deliverable: run
grep -rn "\.free\b" extension/src/and record the result in the PR description. Today this returns exactly one match (extension/src/service-worker/session.ts:26), confirming the SW is the only surface that calls.free()on a wasm-bindgen handle. If a future surface (e.g. an offscreen document, a future setup-flow refactor) adds a second callsite, this PR's grep becomes the baseline that flags the new one. - Lifecycle audit (no code change required, but record findings in PR description): confirm every code path that constructs a
SessionHandleviaunlock(...)ultimately reachesclearCurrent(). The two paths are (a) explicit user lock via the popuplockaction →service-worker/router/popup-only.tslockhandler →clearCurrent(), and (b) inactivity timer expiry →service-worker/index.ts:51-58session-expired callback →clearCurrent(). Both are already wired; this audit just records that the wiring is complete.
- Tests:
- Vitest in
extension/: add a small unit test inextension/src/service-worker/__tests__/session.test.ts(create if absent) that assertsclearCurrent()is a no-op whencurrentisnull, callssetCurrent({ free: spy, value: 1 } as unknown as SessionHandle)thenclearCurrent(), and assertsspywas called exactly once andgetCurrent()returnsnull. Use the existing__stubs__/relicario_wasm.stub.tspattern for the handle shape if convenient. - Optionally extend the lifecycle test: a second case where
free()throws — assert the exception now propagates out ofclearCurrent()(regression coverage for the swallow removal).
- Vitest in
- Effort: S
- Depends on: Phase 1 (the swallow removal is only safe once the Rust side actually does the cleanup work that
.free()is now supposed to do)
Phase 3 — recovery_qr.rs documentation pass
- Goal: raise
crates/relicario-core/src/recovery_qr.rsto the documentation density ofcrypto.rs/imgsecret.rs/backup.rs/tar_safe.rsso a reader landing in the file does not assume it is out-of-the-documented-zone scratch code. - Changes: all in
crates/relicario-core/src/recovery_qr.rs:- Module-level
//!header at the top of the file (above theuseblock on line 1). Three short paragraphs: (1) what the module produces (a 109-byte payload encoded as a QR v40 EcLevel::M SVG that, given the same passphrase, recovers the 32-byte image_secret); (2) why the format is structured this way — XChaCha20-Poly1305 wrapping over an Argon2id-derived "wrap key" derived from the recovery passphrase (b"relicario-recovery-v1\0"domain-separation prefix prevents the wrap key from ever colliding with a vault master key, even if the user reuses their vault passphrase here); (3) parameter-pinning rationale —production_params()deliberately re-statesKdfParams::default()'s values rather than referencing them, because changing the default at the type level must not silently invalidate every recovery QR a user has printed. - ASCII layout diagram above the magic-constant block (lines 7-9):
Pair the diagram with a
// byte field length // ------ -------------- ------ // 0..4 MAGIC = "RREC" 4 // 4..5 VERSION = 0x01 1 // 5..37 kdf_salt 32 (random per QR) // 37..61 wrap_nonce 24 (random per QR) // 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag) // ------------------------------ // total 109const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);static assertion so a future edit that changes the layout cannot drift from the documented total without a compile error. - Doc-comments on the four public items:
RecoveryQrPayload(line 11): one-paragraph summary; note thatas_bytes()is the only accessor and that the bytes are an opaque sealed package — they should travel together (printing them as a QR is the supported transport, but a hex string also works for paranoid sneakernet).generate_recovery_qr(line 45): documents inputs (passphrase, image_secret), output (the 109-byte payload), and the security properties (random kdf_salt + random wrap_nonce per call ⇒ two QRs from the same passphrase + image_secret are different bytes). Cross-referencerecovery_qr_to_svgfor rendering.unwrap_recovery_qr(line 81): documents inputs/output, return-type rationale (Zeroizing<[u8; 32]>so the recovered image_secret zeroes when dropped), and the error vocabulary (RecoveryQrfor format errors,Decryptfor AEAD failure / wrong passphrase — the deliberate non-distinguishing rejection from audit M4).recovery_qr_to_svg(line 122): documents the rendering choice (QR v40, EcLevel::M, 140×140 minimum dimension) and the rationale (109 bytes fits comfortably in v40 at M, leaving recovery-error-correction headroom for a smudged print).
production_params(line 32): add a doc-comment explaining the deliberate divergence-tolerance fromKdfParams::default()— wording: "Pinned at QR-format authoring time.KdfParams::default()may evolve as we re-tune Argon2 cost; this function MUST NOT change values that have ever shipped, because doing so silently invalidates every recovery QR printed under a previous parameter set." Either keep the function-with-comment shape, or per the synthesis suggestion, replace it with aconst RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };ifKdfParamsisconst-constructible (it is — checked viacrypto.rs). Either is acceptable; theconstform is preferred because it makes the "this is not derived from defaults" property visible at the use site.- Optional micro-cleanups while in the file (cite explicitly so they aren't surprising in code review):
- Replace the hand-counted
payload_bytes[5..37],[37..61],[61..109]slice indices with named constants (KDF_SALT_RANGE,WRAP_NONCE_RANGE,CIPHERTEXT_RANGE) derived from the layout offsets. Improves diff-readability if the layout ever extends. - Add a one-line comment to
recovery_kdf_inputdocumenting that the length-prefix onnfc_bytesmirrors the same convention ascrypto::derive_master_key(audit H1 in the design spec) — this is the connective tissue that explains "why is there a(len as u64).to_be_bytes()here too?"
- Replace the hand-counted
- Module-level
- Tests:
- No new test logic needed — the existing
tests/recovery_qr.rsand theRecoveryQr/Decryptround-trips intests/integration.rsalready pin behaviour. - The
const _: () = assert!(...)static assertion is itself the contract test for the layout diagram.
- No new test logic needed — the existing
- Effort: S
- Depends on: none
Phase 4 — Relay launcher (queue.test.ts already done in 061facd)
- Goal: confirm the test fix that already shipped, and bring the launcher script up to the four-role world.
- Changes:
tools/relay/queue.test.ts— no change. Confirmed during planning: line 54 is alreadyassert.ok(isRole("dev-c"));and line 55 is alreadyassert.ok(!isRole("dev-d"));(committed in061facd). This phase records the verification step in the PR description so a reviewer can see the file was checked.tools/relay/start.sh— real changes:- Add a
DEV_C_PROMPTdiscovery line at lines 30-33 alongside the existingPM_PROMPT/DEV_A_PROMPT/DEV_B_PROMPTdefinitions:DEV_C_PROMPT="$(ls -t "$COORD_DIR"/*-dev-c-prompt.md 2>/dev/null | head -1 || echo "(none found)")". - Manual mode (
print_manual_instructions, lines 35-51) — change "Open 3 new terminals" to "Open 4 new terminals" and add theTerminal 4 (Dev C): cat '$DEV_C_PROMPT'line. - tmux mode (
launch_tmux, lines 53-69) — add a fourthtmux new-window -t "$SESSION:" -n "dev-c" "cd '$REPO_ROOT' && claude"line; update the summary print on line 61 from "4 windows: relay, pm, dev-a, dev-b" to "5 windows: relay, pm, dev-a, dev-b, dev-c"; addDev C: $DEV_C_PROMPTto the prompts list (currently lines 64-66). - kitty mode (
launch_kitty, lines 71-86) — add a fourthkitty @ launch --type=tab --tab-title "Dev-C" --hold -- bash -l -i -c "cd '$REPO_ROOT' && claude"line; update the summary print on line 81 from "3 windows (PM, Dev-A, Dev-B)" to "4 windows (PM, Dev-A, Dev-B, Dev-C)"; addDev C: $DEV_C_PROMPTto the prompts list.
- Add a
- Tests: none directly. The relay launcher has no automated tests; verification is "run
bash tools/relay/start.sh --manualand confirm the banner mentions four terminals + the Dev-C prompt path; runbash tools/relay/start.sh --kittyon a kitty terminal and confirm a fourth tab opens." Both checks belong in the PR's manual-test plan. - Effort: S
- Depends on: none
Risks and mitigations
- Adding
impl Dropcould change observable behaviour for any JS code that today holds two references to the sameSessionHandleand frees one of them. wasm-bindgen does not in fact let JS hold two refs to the same Rust struct without explicit cloning — theSessionHandleis moved into JS at construction and.free()consumes the handle pointer. The audit in Phase 2 confirmed only one.free()callsite underextension/src/. The CLI does not touch the WASM crate at all (Rust-side; the CLI usesrelicario-coredirectly). So the realistic blast radius is exactly the one SW callsite this plan also touches. Mitigation: Phase 1's native test explicitly covers "drop the handle, registry is empty," and Phase 2's vitest covers "free is called exactly once." - Removing the
try { current.free() } catch { /* already freed */ }swallow could expose latent bugs that were silently tolerated. Specifically, if any code path today double-frees the handle (callsclearCurrent()twice without asetCurrent()in between), the catch is currently hiding it. Reading the SW lifecycle:clearCurrent()is called from (a) thelockmessage handler, (b) the inactivity-timer session-expired callback, and (c) potentially the unlock-failure cleanup path. The currentif (!current) return;early-out at line 25 already guards the simplest double-free case (callingclearCurrent()twice in a row is safe because the second call no-ops). The only way the catch fires today is if something in WASM raises duringfree()— which post-Phase-1 should be impossible (Drop is infallible;session::removereturns a bool, not a Result). Mitigation: the swallow removal happens after Phase 1's Drop lands, so the catch should now have nothing to catch. If a regression test surfaces a real bug, the right fix is to find and fix the bug, not re-add the swallow. - The
recovery_qr.rsstatic assertion (const _: () = assert!(PAYLOAD_LEN == ...);) requires Rust 1.79+ forconstpanic in test position. The workspace'srust-toolchain.toml(if present) or theCargo.tomlrust-versionfield needs to be checked; if the repo is on an older toolchain, fall back to a runtime#[test] fn payload_len_matches_layout() { assert_eq!(PAYLOAD_LEN, 4 + 1 + 32 + 24 + 48); }— same effect, slightly less elegant. Verify during implementation; not a blocker. - Phase 4's launcher change is untested by automation. A typo in the kitty/tmux command lines won't surface until someone runs
start.sh --kittyorstart.sh --tmux. Mitigation: the PR description includes an explicit manual-test plan ("run all three modes and confirm 4 windows + Dev-C prompt resolution") and the change pattern is symmetric to the existing Dev-A/Dev-B lines, minimizing typo surface. - The
wasm-bindgen-testvariant in Phase 1 only runs underwasm-pack test --node, which is not part of the project's defaultcargo testcycle. The native#[test]inmod session_testsis the one that protects against regressions in CI. The wasm-bindgen-test is included for symmetry and to exercise the actual WASM target's Drop behaviour — it should be invoked at least once during PR review and added to a follow-up CI workflow if the project's test infrastructure ever expands to wasm-pack.
Out of scope
This plan deliberately does not address, and explicitly leaves to other plans or the P2/P3 tail:
- Plan B's CLI restructure work in its entirety (P1.2 main.rs split, P1.3 git-shell error UX, P1.10 parser migration to core, plus the CLI P2/P3 entries). These touch
crates/relicario-cli/and are independent of this PR. - Plan C's extension restructure work in its entirety (P1.4 setup.ts WASM extraction, P1.5 vault.ts split, P1.6 shared/state.ts typing, P1.9 SW router helper extraction, plus the extension P2/P3 entries). These touch
extension/src/and are independent of this PR — except for the single SW file that Phase 2 of this plan touches, which is small and disjoint from Plan C's surfaces. - The P2 WASM cleanups DEV-B flagged: redundant
need_key + with(...).unwrap()double-lookup,Vec<u8>getter clones, thewasm_*_recovery_qrnaming inconsistency, and thedevice.rsvssession.rsconcurrency-primitive split. Each is independently correct and worth doing, but each is its own one-paragraph change and the PR is already covering its theme. - DEV-C's other relay P2s: queue TTL/persistence/cap, the
tools/relay/call.pyandcall.tstracking decision,server.tsper-connection factory comment. None block this PR. - The eight "Open architectural decisions" at the bottom of the synthesis (deliberate-Drop-omission, parser migration timing, bootstrap rule,
Locksubcommand visibility, Task 12 status, call.py tracking, snake_case-vs-camelCase WASM naming,.gitea_env_varsgitignore). Most are user-judgement calls; this PR confirms #1 (the omission was not deliberate; the fix lands here) but takes no position on #2-#8. - Anything touching the in-flight uncommitted v0.5.x work — manifests, glyphs, vault.css, relay tooling beyond
start.sh. Those changes are on parallel branches and are explicitly hands-off for this PR.
Done criteria
A reviewer can confirm this plan shipped by checking:
crates/relicario-wasm/src/lib.rscontainsimpl Drop for SessionHandlewhose body removes the registry entry.crates/relicario-wasm/tests/session_drop.rs(or equivalent native#[test]insidemod session_tests) asserts that dropping aSessionHandleremoves its handle id from the session registry.cargo test -p relicario-wasmpasses the new test.extension/src/service-worker/session.tsno longer containstry { current.free(); } catch. The body ofclearCurrent()callscurrent.free()unconditionally, then setscurrent = null.grep -rn "\.free\b" extension/src/returns exactly one match (the SW callsite). PR description records this audit.extension/src/service-worker/__tests__/session.test.tscoversclearCurrent()with both the no-op (no current handle) and the propagating-throw cases.npm testfromextension/passes.crates/relicario-core/src/recovery_qr.rshas a module-level//!header explaining format + domain-separation + parameter-pinning, an ASCII layout diagram next to the magic constants, doc-comments onRecoveryQrPayload,generate_recovery_qr,unwrap_recovery_qr, andrecovery_qr_to_svg, plus either aconst RECOVERY_PRODUCTION_PARAMSor a doc-commentedproduction_params()explaining why it deliberately diverges fromKdfParams::default().cargo test -p relicario-corestill passes.tools/relay/queue.test.ts:54-55verified to already match the new role union (no change required, recorded in PR description).tools/relay/start.shdiscovers a*-dev-c-prompt.md, opens a fourth window/terminal in tmux and kitty modes, and the manual-mode banner mentions all four roles.bash tools/relay/start.sh --manualshows "Open 4 new terminals" and lists the Dev-C prompt path.