Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14 KiB
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
- Provide a purely-GUI path to attach a new device to an existing vault, without touching the CLI.
- Make destructive overwrite of an existing vault impossible from the wizard.
- Verify the user's passphrase + reference image actually decrypt the existing vault before registering a new device key — no silently broken attachments.
- 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 initkeeps 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:
host.listDir('.relicario')— collect filenames.host.listDir('')— check root formanifest.enc.- Vault is "present" if any of
.relicario/salt,.relicario/params.json,manifest.encexist. - 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:
GET .relicario/salt,.relicario/params.json,manifest.encfrom host.wasm.unlock(passphrase, referenceJpegBytes, salt, paramsJson)→ handle.wasm.manifest_decrypt(handle, manifestEnc)→ JSON.- On any throw:
wasm.lock(handle)if a handle was created, setstate.error = "Could not decrypt vault — wrong passphrase or reference image.", stay on form, no remote writes. - 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 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 ashaparameter creates only; if the file exists it returns 422. - Gitea Contents API: same semantics —
POSTto create,PUT(withsha) 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.tomlcrates/relicario-cli/Cargo.tomlcrates/relicario-wasm/Cargo.tomlextension/manifest.jsonextension/package.json
Tag v0.2.0 after merge. Release notes should call out:
- 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.
- 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=newhappy path against an empty mock host — unchanged from existing tests.mode=newagainst a host that already returns.relicario/salt— wizard refuses, offers switch.mode=attachagainst an empty host — wizard refuses, offers switch.mode=attachhappy path with valid passphrase + reference —add_devicecalled, config saved.mode=attachwith wrong passphrase — error displayed, no remote writes occur, no orphan device pubkey.mode=attachwith 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/adjustadd_devicehandler if it does not read-modify-writedevices.json.extension/src/service-worker/git-host.ts(or whereverwriteFilelives) — verify create-only semantics on Step 3a's push.- All five package version files (above).