vault-adoption-design.md

12.2 KB · raw

rendered · source

1# Vault Adoption — Second-Device Setup over SAS-Gated Pairing23**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, so6it ends up with the *same* root key → same `vaultId` → sync brings the entries,7and Pro rides along. Decision taken: **transfer the root key over the8SAS-gated Noise channel** (vs. an export bundle or recovery-words path).910> 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.1213---1415## 1. Why this is the only mechanism that actually works1617From the architecture (see file refs in §9):1819- 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.3233**Conclusion:** adoption = move the existing root key to the new device. Once it34has the key, the existing sync transport fills the entries — we do **not**35re-transfer entries inline.3637---3839## 2. Roles & terminology4041| 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. |4748The channel is **device↔device chirp** (same-room audio + Noise NK). The49desktop helper (peach-sync) is **not** an adoption source — it stores opaque50entry blobs and never holds the root key. (Post-adoption, the helper is still a51valid *entry* source because the receiver can now decrypt its blobs.)5253---5455## 3. UX flow5657**Receiver (new device):**581. Onboarding gains a third path beside "Create vault" / "Restore from backup":59   **"I already have Peach on another device."**602. Receiver sets a **local password** (wraps the adopted key on this device) and61   confirms it — same strength gate as create-vault.623. Receiver enters adoption-pairing (chirp, same room). Shows the SAS.634. On success: "Joined <Source>'s vault" → vault opens, sync begins filling entries.6465**Source (existing device):**661. 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."**692. The SAS confirmation **is** the consent gate. Reject → abort, nothing sent.7071---7273## 4. Protocol7475Built on the existing chirp stack: chirp ephemeral exchange → Noise NK →76shared handshake hash → SAS. All adoption frames travel **inside the Noise77transport** (ChaCha20-Poly1305) and are exchanged **only after both sides78confirm the SAS**.7980The receiver is vault-less, so it has **no long-term identity** to put in a81normal `PAIR_IDENTITY`. Adoption is therefore an asymmetric sub-protocol the82receiver signals by sending `ADOPT_REQUEST` *instead of* `PAIR_IDENTITY`.8384### Frames (JSON, snake_case, browser/Android parity)8586```87ADOPT_REQUEST   { type:"ADOPT_REQUEST", device_id, device_name, device_type }88ADOPT_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? } }95ADOPT_IDENTITY  { type:"ADOPT_IDENTITY", device_id, device_name, device_type,96                  long_term_public_key_hex, signing_public_key_hex }97```9899### Sequence (after Noise NK established + SAS confirmed on BOTH)100101```102Receiver → Source : ADOPT_REQUEST            (receiver is blank; no keys yet)103   Source shows consent modal (SAS doubles as approval). On reject → close, abort.104Source   → 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)112Receiver → Source : ADOPT_IDENTITY           (receiver's now-derivable identity)113   Source: upsert receiver as a paired device114Both: trigger a sync round → entries flow over the existing transport.115```116117If the receiver aborts before `createVaultFromRootKey`, it remains vault-less.118The source never persists anything until it receives `ADOPT_IDENTITY`.119120### Recovery words after adoption121122`createVaultFromRootKey` regenerates the recovery escrow from the adopted key, so123the 12 recovery words are **identical** to the source's (they derive from124`rootKey[0..16]`). Recovery is consistent across all devices on the vault. The125receiver's *local password* is independent.126127---128129## 5. Threat model130131What changes vs. today's pairing (which only swaps public keys):132133- **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.149150---151152## 6. Vault-less execution model (browser)153154The chirp orchestrator runs in the **popup** (needs `getUserMedia`); the155background owns the vault. Adoption adds background handlers that **do not156require an unlocked vault**:157158- `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.165166The root key crosses **popup→background** via runtime messaging (in-process,167same extension) — acceptable; it's the same boundary `CREATE_VAULT` already uses168for the password.169170---171172## 7. Cross-platform contract173174| 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.) |179180Frames are additive and snake_case so the two app codebases interoperate; the181SAS KAT and Noise KATs already pin cross-impl agreement.182183---184185## 8. Open decisions (need sign-off before build)1861871. **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.1912. **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.1953. **Same-room only (chirp) for v1**, with helper-relay rendezvous as a later196   add? *Recommendation: yes, chirp-only v1.*197198---199200## 9. Implementation map (file references)201202**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).211212**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`).218219**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.223224**Android mirror:** `ChirpPairing.kt`, `ChirpIdentity.kt`,225`ui/pairing/ChirpPairViewModel.kt`, `VaultRepository`, onboarding.226227---228229## 10. Test plan230231- `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).240241---242243## 11. Build sequencing (once decisions in §8 are settled)2442451. **vault.ts `createVaultFromRootKey` + `CREATE_VAULT_FROM_ROOT_KEY`** (+ tests) — pure, testable, no UI.2462. **Adoption sub-protocol** in `chirp-handshake.ts`/`chirp-pair.ts` (+ handshake KAT).2473. **Onboarding entry + adopt view + source consent UI.**2484. **Android mirror.**2495. Version bumps per repo; full test + build; coordinated promotion (browser +250   Android land together, like the license-transfer change).