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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: `<sha7>` by `<author>` on `<date>`.
|
||||
>
|
||||
> 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).
|
||||
Reference in New Issue
Block a user