Files
relicario/docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md
adlee-was-taken 44fc157f35 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>
2026-04-27 17:33:07 -04:00

14 KiB
Raw Blame History

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 35. 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:

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 05 (was 15). 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).