6c2063ea1afd

docs: vault-adoption design spec — root key over SAS-gated pairing (ADOPT_REQUEST/GRANT/IDENTITY), createVaultFromRootKey, vault-less receiver, threat model, cross-platform contract; sign-off open decisions in §8

time
2026-06-19 18:22 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
vault adoption design
parent
1707dae72817
1 file vault-adoption-design.md+250 −0
vault-adoption-design.md+250 −0
@@ -0,0 +1,250 @@1+# Vault Adoption — Second-Device Setup over SAS-Gated Pairing2+3+**Status:** Design spec (no code yet) — awaiting sign-off before crypto ships.4+**Author:** claude-opus-4-8, 2026-06-19.5+**Scope:** Let a brand-new device adopt an existing device's vault by pairing, so6+it ends up with the *same* root key → same `vaultId` → sync brings the entries,7+and Pro rides along. Decision taken: **transfer the root key over the8+SAS-gated Noise channel** (vs. an export bundle or recovery-words path).9+10+> This is the crown-jewels path: the 32-byte **root key** crosses the wire.11+> Everything else in this doc exists to make that one transfer safe.12+13+---14+15+## 1. Why this is the only mechanism that actually works16+17+From the architecture (see file refs in §9):18+19+- Sync identity (X25519), manifest signing key (Ed25519), per-entry envelope20+  keys, and `vaultId` **all derive from the 32-byte root key**21+  (`vault.ts:getRootKeyHex`, `sync-identity.ts:computeVaultId`,22+  `chirp-sync-vault-adapter.ts` per-entry HKDF).23+- The sync coordinator refuses to sync across **different `vaultId`s**24+  (`chirp-sync-coordinator.ts` SYNC_HELLO check). So two devices must share the25+  *exact* root key, or nothing syncs.26+- **Recovery words encode only `rootKey[0..16]` (128-bit)** and merely derive the27+  key that *unwraps* the stored `recovery_wrapped_root_key` blob28+  (`recovery-words.ts:23-26`). Words alone cannot reconstruct the root key on a29+  device that has no vault blob.30+- Import/restore (`RESTORE_ENTRIES`) deliberately mints a **new** root key →31+  new `vaultId` → does not sync. That's why "import a backup" is not adoption.32+33+**Conclusion:** adoption = move the existing root key to the new device. Once it34+has the key, the existing sync transport fills the entries — we do **not**35+re-transfer entries inline.36+37+---38+39+## 2. Roles & terminology40+41+| Term | Meaning |42+|---|---|43+| **Source** | Existing device, vault unlocked, holds the root key. Grants adoption. |44+| **Receiver** | Brand-new (vault-less) device. Requests adoption, receives the root key, stands up a local vault around it. |45+| **Root key** | 32 random bytes; the secret everything derives from. Crown jewels. |46+| **Local password** | Per-device password the receiver chooses; only wraps the root key at rest on *that* device. Need not match the source's password. |47+48+The channel is **device↔device chirp** (same-room audio + Noise NK). The49+desktop helper (peach-sync) is **not** an adoption source — it stores opaque50+entry blobs and never holds the root key. (Post-adoption, the helper is still a51+valid *entry* source because the receiver can now decrypt its blobs.)52+53+---54+55+## 3. UX flow56+57+**Receiver (new device):**58+1. Onboarding gains a third path beside "Create vault" / "Restore from backup":59+   **"I already have Peach on another device."**60+2. Receiver sets a **local password** (wraps the adopted key on this device) and61+   confirms it — same strength gate as create-vault.62+3. Receiver enters adoption-pairing (chirp, same room). Shows the SAS.63+4. On success: "Joined <Source>'s vault" → vault opens, sync begins filling entries.64+65+**Source (existing device):**66+1. While unlocked, a peer requests adoption → modal:67+   **"A new device wants to join this vault. Verify the code matches: `1234`.68+   Allowing this copies your entire vault and Pro to that device."**69+2. The SAS confirmation **is** the consent gate. Reject → abort, nothing sent.70+71+---72+73+## 4. Protocol74+75+Built on the existing chirp stack: chirp ephemeral exchange → Noise NK →76+shared handshake hash → SAS. All adoption frames travel **inside the Noise77+transport** (ChaCha20-Poly1305) and are exchanged **only after both sides78+confirm the SAS**.79+80+The receiver is vault-less, so it has **no long-term identity** to put in a81+normal `PAIR_IDENTITY`. Adoption is therefore an asymmetric sub-protocol the82+receiver signals by sending `ADOPT_REQUEST` *instead of* `PAIR_IDENTITY`.83+84+### Frames (JSON, snake_case, browser/Android parity)85+86+```87+ADOPT_REQUEST   { type:"ADOPT_REQUEST", device_id, device_name, device_type }88+ADOPT_GRANT     { type:"ADOPT_GRANT",89+                  root_key_hex,            // 64 lowercase hex chars (32 bytes)90+                  vault_id,                // for receiver sanity-check91+                  license?,                // signed PPK- blob, if source is Pro92+                  source: { device_id, device_name, device_type,93+                            long_term_public_key_hex, signing_public_key_hex,94+                            ws_port? } }95+ADOPT_IDENTITY  { type:"ADOPT_IDENTITY", device_id, device_name, device_type,96+                  long_term_public_key_hex, signing_public_key_hex }97+```98+99+### Sequence (after Noise NK established + SAS confirmed on BOTH)100+101+```102+Receiver → Source : ADOPT_REQUEST            (receiver is blank; no keys yet)103+   Source shows consent modal (SAS doubles as approval). On reject → close, abort.104+Source   → Receiver: ADOPT_GRANT             (root key + source identity + license?)105+   Receiver:106+     - assert root_key_hex is 64 hex chars107+     - createVaultFromRootKey(localPassword, root_key_hex)   [new vault.ts fn]108+     - assert computeVaultId(rootKey) == vault_id            [sanity]109+     - if license present and we're not Pro → validate+store (existing path)110+     - upsert `source` as a paired device (trustSource:"direct")111+     - now the receiver HAS an identity (derived from the adopted root key)112+Receiver → Source : ADOPT_IDENTITY           (receiver's now-derivable identity)113+   Source: upsert receiver as a paired device114+Both: trigger a sync round → entries flow over the existing transport.115+```116+117+If the receiver aborts before `createVaultFromRootKey`, it remains vault-less.118+The source never persists anything until it receives `ADOPT_IDENTITY`.119+120+### Recovery words after adoption121+122+`createVaultFromRootKey` regenerates the recovery escrow from the adopted key, so123+the 12 recovery words are **identical** to the source's (they derive from124+`rootKey[0..16]`). Recovery is consistent across all devices on the vault. The125+receiver's *local password* is independent.126+127+---128+129+## 5. Threat model130+131+What changes vs. today's pairing (which only swaps public keys):132+133+- **Asset on the wire:** the 32-byte root key (full vault control), not just a134+  sync link. Protected by (a) Noise NK transport encryption, (b) the135+  user-confirmed **SAS** out-of-band check, (c) explicit source-side consent copy136+  that names the consequence.137+- **Residual risk:** an in-room MITM who completes the handshake gets the root138+  key *iff the user mis-confirms the SAS*. Same ~1e-6 SAS bound as pairing, but139+  higher blast radius. Mitigations to decide (§8): stronger consent on the140+  source; optional source re-auth (re-enter master password) before `ADOPT_GRANT`.141+- **No plaintext at rest in transit:** root key handled as transient bytes,142+  **zeroed** after `createVaultFromRootKey`; never logged; never written except as143+  the wrapped blobs the vault already uses.144+- **Sanity gates:** receiver verifies 32-byte length and that145+  `computeVaultId(rootKey) === grant.vault_id` before committing.146+- **Downgrade/abuse:** `ADOPT_*` are new frames; a pre-adoption peer that147+  receives `ADOPT_REQUEST` errors out (can't be tricked into granting). Only148+  updated source devices can grant.149+150+---151+152+## 6. Vault-less execution model (browser)153+154+The chirp orchestrator runs in the **popup** (needs `getUserMedia`); the155+background owns the vault. Adoption adds background handlers that **do not156+require an unlocked vault**:157+158+- `CREATE_VAULT_FROM_ROOT_KEY { password, rootKeyHex }` → `vault.createVaultFromRootKey(...)`.159+  Vault-less-allowed (it *creates* the vault). Returns ok + computed vaultId.160+- The popup receives `ADOPT_GRANT` over the channel, hands `rootKeyHex` to the bg161+  via that message, then proceeds to identity exchange + sync.162+- Source side: a normal (unlocked) device's pairing entry point must recognize an163+  inbound `ADOPT_REQUEST` and run the grant path (with the consent gate) instead164+  of the normal identity exchange.165+166+The root key crosses **popup→background** via runtime messaging (in-process,167+same extension) — acceptable; it's the same boundary `CREATE_VAULT` already uses168+for the password.169+170+---171+172+## 7. Cross-platform contract173+174+| Repo | Work |175+|---|---|176+| **peach-browser** | `createVaultFromRootKey`; `CREATE_VAULT_FROM_ROOT_KEY` handler; adoption sub-protocol (new `chirp-adopt.ts` or extend `chirp-handshake.ts`/`chirp-pair.ts`) for both receiver and source; onboarding entry + adopt view; source-side `ADOPT_REQUEST` recognition + consent UI. |177+| **peach-android** | Mirror: `VaultRepository.createVaultFromRootKey`; adoption frames in `ChirpPairing.kt`; onboarding "another device" entry; source-side consent. Public key & license infra already present (license transfer just landed). |178+| **peach-sync (Zig)** | **None for adoption** — the helper is not a key source. (It remains an entry source post-adoption. A future "helper rendezvous relay" so two non-co-located devices can adopt without same-room audio is **out of scope** here and noted as a follow-up.) |179+180+Frames are additive and snake_case so the two app codebases interoperate; the181+SAS KAT and Noise KATs already pin cross-impl agreement.182+183+---184+185+## 8. Open decisions (need sign-off before build)186+187+1. **Source re-auth before granting?** Require the source user to re-enter their188+   master password (or biometric) in the consent modal before `ADOPT_GRANT`?189+   *Recommendation: yes* — the SAS proves channel integrity but not that the190+   person at the source consents to exporting the whole vault.191+2. **Receiver password timing:** set the local password *before* the SAS (so192+   success is one tap) or *after* a confirmed grant? *Recommendation: before* —193+   keeps the post-SAS path non-interactive and avoids holding the root key in194+   memory while prompting.195+3. **Same-room only (chirp) for v1**, with helper-relay rendezvous as a later196+   add? *Recommendation: yes, chirp-only v1.*197+198+---199+200+## 9. Implementation map (file references)201+202+**Vault / keys (peach-browser):**203+- `src/background/vault.ts` — `createVault` (965-1007), `generateAndWrapRootKey`204+  (301-342), `wrapRootKeyForDerivedKey`, `unwrapRootKeyForRecovery` (349-381),205+  `getRootKeyHex` (1456-1458). **New:** `createVaultFromRootKey(password, rootKeyHex)`206+  — mirror `createVault` but take the root key as input instead of generating it.207+- `src/background/messages.ts` — `CREATE_VAULT` (285-299), `RECOVER_WITH_WORDS`208+  (364-399), sync handlers (1318-1407). **New:** `CREATE_VAULT_FROM_ROOT_KEY`.209+- `src/shared/sync-identity.ts` — `computeVaultId` (75-82).210+- `src/shared/recovery-words.ts` — escrow encoding (23-62).211+212+**Pairing (peach-browser):**213+- `src/shared/chirp-handshake.ts` — `LocalIdentity`/`PairingFrame` (49-87),214+  initiator/responder, `derivePairingSAS`. **New:** adoption frames + a215+  receiver/source path that swaps the identity exchange for `ADOPT_*`.216+- `src/popup/chirp-pair.ts` — orchestrator, `gateOnSAS`. **New:** adoption deps217+  (`requestAdoption`, `grantAdoption`, `createVaultFromRootKey`).218+219+**Onboarding (peach-browser):**220+- `src/popup/views/Onboarding.tsx`, `src/popup/views/Lock.tsx` (create + restore221+  footer), `src/popup/App.tsx` (view state machine). **New:** "another device"222+  entry + adopt view.223+224+**Android mirror:** `ChirpPairing.kt`, `ChirpIdentity.kt`,225+`ui/pairing/ChirpPairViewModel.kt`, `VaultRepository`, onboarding.226+227+---228+229+## 10. Test plan230+231+- `createVaultFromRootKey` round-trip: unlock with the local password yields the232+  same root key; `computeVaultId` equals the source's.233+- Recovery words from an adopted vault equal the source's words.234+- Adoption handshake KAT: receiver obtains the root key; mutual identity235+  exchange registers both devices; `vault_id` sanity check passes.236+- SAS-reject / consent-reject aborts **before** any `ADOPT_GRANT` is sent and the237+  receiver stays vault-less.238+- Cross-impl: an Android source ↔ browser receiver (and vice-versa) agree on the239+  SAS and the frame shapes (extend existing cross-platform KATs).240+241+---242+243+## 11. Build sequencing (once decisions in §8 are settled)244+245+1. **vault.ts `createVaultFromRootKey` + `CREATE_VAULT_FROM_ROOT_KEY`** (+ tests) — pure, testable, no UI.246+2. **Adoption sub-protocol** in `chirp-handshake.ts`/`chirp-pair.ts` (+ handshake KAT).247+3. **Onboarding entry + adopt view + source consent UI.**248+4. **Android mirror.**249+5. Version bumps per repo; full test + build; coordinated promotion (browser +250+   Android land together, like the license-transfer change).

view file