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).