# 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).