From 44fc157f352d82848a8413bfa72d02aea1a127e7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 17:33:07 -0400 Subject: [PATCH] docs: spec for attach-existing-vault wizard split (v0.2.0) Setup wizard currently overwrites existing vaults silently. Adds a mode picker (create new / attach this device), a vault-presence probe after the connection test, and a Step 3b that verifies passphrase + reference image by decrypting the manifest before registering a new device key. Refuses destructive overwrite from the GUI; users wanting a clean slate must delete the repo via their host's web UI. Co-Authored-By: Claude Opus 4.7 --- ...2026-04-27-attach-existing-vault-design.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md diff --git a/docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md b/docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md new file mode 100644 index 0000000..55eaccc --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md @@ -0,0 +1,225 @@ +# Attach existing vault — wizard split + clobber guard (v0.2.0) + +**Status:** design +**Target release:** v0.2.0 +**Scope:** extension only (`extension/src/setup/`, `extension/src/service-worker/`) +**Out of scope:** CLI `init` reconnect support, multi-vault per install, in-wizard "destroy and recreate" flow + +## Background + +Today the setup wizard (`extension/src/setup/setup.ts`) has one flow: create a brand-new vault. Step 2 only checks that the configured remote is reachable; it does not detect whether that remote already contains a relicario vault. Step 3's "create vault" then writes `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, and `manifest.enc` unconditionally — silently overwriting any existing vault on the remote. + +**Observed failure:** uninstalling and reinstalling the extension while pointed at a populated test repo wipes the manifest with no warning. The user's test entries are gone. + +The service worker already exposes `add_device`, `save_setup`, `unlock`, and `manifest_decrypt` machinery. The building blocks for "attach this device to an existing vault" exist; only the wizard UI is missing. + +## Goals + +1. Provide a purely-GUI path to attach a new device to an existing vault, without touching the CLI. +2. Make destructive overwrite of an existing vault impossible from the wizard. +3. Verify the user's passphrase + reference image actually decrypt the existing vault before registering a new device key — no silently broken attachments. +4. Keep the existing "create new vault" flow working, with no behavioural regressions for greenfield setups. + +## Non-goals + +- Recovering from a partially-clobbered vault (out of scope; users with damaged remotes use git history). +- A "really nuke and recreate" escape hatch in the wizard. Users who genuinely want to start over delete the repo via the host's web UI. +- CLI parity. `relicario init` keeps its current "always fresh" semantics for now; a separate spec will cover CLI attach. + +## UX flow + +The wizard grows a leading **mode picker** (Step 0) and a parallel attach branch through Steps 3–5. Steps 1, 2, and 4 are shared between modes. + +``` + ┌──────── Step 0: mode ────────┐ + │ create new | attach │ + └──────────────┬───────────────┘ + ▼ + ┌──── Step 1: host type ───────┐ + └──────────────┬───────────────┘ + ▼ + ┌──── Step 2: host config ─────┐ + │ URL + repo + token + test │ + │ → vault-presence probe │ + └──────┬─────────────┬─────────┘ + new │ │ attach + ▼ ▼ + ┌── Step 3a: carrier JPEG ─┐ ┌── Step 3b: reference JPEG ──┐ + │ + passphrase + confirm │ │ + passphrase │ + │ + zxcvbn ≥ 3 gate │ │ + verify-decrypt round-trip │ + └────────────┬─────────────┘ └─────────────┬───────────────┘ + ▼ ▼ + ┌── Step 4: device name (shared) ──┐ + └──────────────┬───────────────────┘ + ▼ + ┌── Step 5: register device + save config ──┐ + │ new: + download reference.jpg │ + │ attach: skip download │ + └───────────────────────────────────────────┘ +``` + +The progress bar grows from 5 to 6 segments; Step 0 is the new leading segment. + +### Step 0: mode picker + +Two large buttons. No host configuration, no other inputs. Sets `state.mode` to `'new'` or `'attach'`. Helper copy under each: + +- *create new vault* — "I'm setting up relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository." +- *attach this device* — "I already have a vault on another device. Connect this browser to it using my passphrase and reference image." + +### Step 1: host type + +Unchanged. Gitea/GitHub toggle + token-creation instructions. Shared by both modes. + +### Step 2: host config + presence probe + +Connection test is unchanged. **After** a successful test, run a vault-presence probe before allowing transition to Step 3: + +1. `host.listDir('.relicario')` — collect filenames. +2. `host.listDir('')` — check root for `manifest.enc`. +3. Vault is "present" if any of `.relicario/salt`, `.relicario/params.json`, `manifest.enc` exist. +4. If vault is present, also fetch the most-recent commit metadata (`sha`, `author`, `date`) via the host's commits API for display. This is best-effort — failure to fetch metadata does not block the flow. + +The probe result drives a banner under the connection-test row, with one of four states: + +| mode | vault present | UI | +| --------- | ------------- | ------------------------------------------------------------------------------------ | +| `new` | no | green banner: "✓ repo is empty — ready to create a new vault." Next button enabled. | +| `new` | yes | red banner + warning card. Next disabled. Buttons: `[switch to attach]` `[back]`. | +| `attach` | yes | green banner + confirmation card with last-commit metadata. Next button enabled. | +| `attach` | no | red banner: "no vault found in this repo." Buttons: `[switch to new mode]` `[back]`. | + +The "switch mode" buttons preserve all entered host config so the user does not retype anything. + +**Warning card copy (mode=new, vault present):** + +> ⚠ This repository already contains a relicario vault. +> Last commit: `` by `` on ``. +> +> Creating a new vault here would overwrite the existing one and **destroy all data inside**. To use this vault on this device, switch to *attach* mode instead. +> +> If you really mean to start over, delete the repository via your git host's web UI and come back here. + +No "type the repo name to confirm" escape; deliberate friction routed through the host's own UI. + +### Step 3a: create vault (new mode) + +Largely unchanged from today's Step 3. Carrier JPEG + passphrase + confirm + zxcvbn ≥ 3 gate. On submit, embed image secret, derive key, encrypt empty manifest, push files. The presence probe already ran in Step 2, so the upload here is conditional on the repo still being empty *at probe time* — race-window narrowing belongs in the write layer (see "TOCTOU" below). + +### Step 3b: attach (attach mode) + +New step. Inputs: + +- **Reference image (JPEG)** — file picker. Help text emphasises *reference, not carrier*: "upload the reference JPEG you saved when you first created this vault. Not the original photo — the one with the embedded secret." +- **Passphrase** — single field, no confirm (user is proving they know it, not setting a new one). Re-uses the same password input + show/hide eye toggle as Step 3a. + +No zxcvbn meter on this step — the user does not get to set a passphrase, only enter the existing one. + +On submit: + +1. `GET .relicario/salt`, `.relicario/params.json`, `manifest.enc` from host. +2. `wasm.unlock(passphrase, referenceJpegBytes, salt, paramsJson)` → handle. +3. `wasm.manifest_decrypt(handle, manifestEnc)` → JSON. +4. On any throw: `wasm.lock(handle)` if a handle was created, set `state.error = "Could not decrypt vault — wrong passphrase or reference image."`, stay on form, no remote writes. +5. On success: stash decrypted manifest JSON and live handle in `state.verifiedHandle`. Continue to Step 4. + +The verified handle is held only for the duration of the wizard. It is **not** pushed to the SW — after Step 5 finishes and the user opens the popup, they unlock again normally. The handle is locked at end-of-wizard regardless. + +### Step 4: device name + +Unchanged. Default name `${browser} on ${os}`. Shared by both modes. + +### Step 5: register device + save config + +Differences by mode: + +| element | new mode | attach mode | +| --------------------------------- | -------- | ----------- | +| success header | "vault created" | "device attached" | +| reference.jpg download button | shown | hidden | +| save-config-to-extension button | shown | shown | +| add_device call | yes | yes | + +Both modes call `add_device` via the SW with a freshly-generated keypair, write the private key to `chrome.storage.local`, and have the SW push the new pubkey into `.relicario/devices.json`. + +**Implementation note for the plan:** verify the SW's `add_device` handler reads `devices.json` from the host, appends the new entry, and writes it back (read-modify-write). If it currently overwrites with a single-entry array, that is a pre-existing bug surfaced by attach mode and must be fixed as part of this work. + +## State changes + +`WizardState` gains: + +```ts +mode: 'new' | 'attach' | null; // null until Step 0 chosen +referenceImageBytesAttach: Uint8Array | null; +vaultProbe: { + exists: boolean; + lastCommit?: { sha: string; author: string; date: string }; +} | null; +verifiedHandle: number | null; // WASM handle from Step 3b verify +``` + +`carrierImageBytes` is kept distinct from `referenceImageBytesAttach` so the two paths cannot accidentally read each other's bytes. + +`step` is renumbered to 0–5 (was 1–5). The progress bar grows to 6 segments. + +## TOCTOU on the new-vault write path + +The Step 2 probe is best-effort. A user could pass the probe with an empty repo, then between Step 2 and Step 3a's push, another client (or a previous wizard run) could initialise the same repo. The wizard's defence is the git-host write layer, not a re-probe: + +- GitHub Contents API: `PUT /repos/{owner}/{repo}/contents/{path}` without a `sha` parameter creates only; if the file exists it returns 422. +- Gitea Contents API: same semantics — `POST` to create, `PUT` (with `sha`) to update. + +Verify in the implementation plan that `host.writeFile` on the new path uses create-only semantics when called from Step 3a. If it currently does blind PUT-or-create, harden it for this code path. This is defence in depth — if it fails, the user gets a writeFile error mid-push and aborts, which is non-destructive (worst case: they leave a partial set of files behind, fixable by a second run that detects the partial vault and refuses). + +The attach path does not have this concern — it only writes `devices.json`, and that is read-modify-write under the SW's existing handler. + +## Error UX summary + +| condition | behaviour | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------ | +| connection test fails | red banner, stay on Step 2 | +| probe fails (network) | red banner "could not check repo state — retry"; do not proceed to Step 3 | +| mode=new, probe finds vault | warning card; only `[switch to attach]` or `[back]` advance | +| mode=attach, probe finds empty repo | warning card; only `[switch to new]` or `[back]` advance | +| mode=attach, decrypt fails in Step 3b | red banner "wrong passphrase or reference image"; stay on form; lock any partial handle | +| mode=new, conditional create rejects in Step 3a | red error referencing the file path that was rejected; advise re-running setup | +| `add_device` fails | red banner on Step 5; config save still succeeds; user can retry | + +## Version + rollout + +This is the first user-facing feature delivery since v0.1.0 and includes a fix for an unflagged data-loss bug. Bump all package versions to **0.2.0**: + +- `crates/relicario-core/Cargo.toml` +- `crates/relicario-cli/Cargo.toml` +- `crates/relicario-wasm/Cargo.toml` +- `extension/manifest.json` +- `extension/package.json` + +Tag `v0.2.0` after merge. Release notes should call out: + +1. **Fix:** running setup against a remote that already contained a vault would silently overwrite it. Setup now refuses to overwrite and offers an attach path instead. +2. **Feature:** wizard now supports attaching a new device to an existing vault directly from the GUI (passphrase + reference image, no CLI). + +## Testing + +Unit/integration coverage to add: + +- `mode=new` happy path against an empty mock host — unchanged from existing tests. +- `mode=new` against a host that already returns `.relicario/salt` — wizard refuses, offers switch. +- `mode=attach` against an empty host — wizard refuses, offers switch. +- `mode=attach` happy path with valid passphrase + reference — `add_device` called, config saved. +- `mode=attach` with wrong passphrase — error displayed, no remote writes occur, no orphan device pubkey. +- `mode=attach` with mismatched reference image (right format, wrong embedded secret) — same as above. +- Mode-switch buttons preserve host URL / repo / token across the switch. + +Manual verification: + +- End-to-end on a real Gitea repo: create vault on workstation A, install fresh extension on workstation A, run attach wizard, verify popup unlocks and lists existing items unchanged. + +## File touchpoints + +- `extension/src/setup/setup.ts` — most of the work; new render functions, state additions, mode threading. +- `extension/src/setup/setup.html` — possibly minor adjustments for a 6-segment progress bar. +- `extension/src/service-worker/index.ts` — verify/adjust `add_device` handler if it does not read-modify-write `devices.json`. +- `extension/src/service-worker/git-host.ts` (or wherever `writeFile` lives) — verify create-only semantics on Step 3a's push. +- All five package version files (above).