# Vault Adoption — Second-Device Setup over SAS-Gated Pairing **Status:** Design spec (no code yet) — awaiting sign-off before crypto ships. **Author:** claude-opus-4-8, 2026-06-19. **Scope:** Let a brand-new device adopt an existing device's vault by pairing, so it ends up with the *same* root key → same `vaultId` → sync brings the entries, and Pro rides along. Decision taken: **transfer the root key over the SAS-gated Noise channel** (vs. an export bundle or recovery-words path). > This is the crown-jewels path: the 32-byte **root key** crosses the wire. > Everything else in this doc exists to make that one transfer safe. --- ## 1. Why this is the only mechanism that actually works From the architecture (see file refs in §9): - Sync identity (X25519), manifest signing key (Ed25519), per-entry envelope keys, and `vaultId` **all derive from the 32-byte root key** (`vault.ts:getRootKeyHex`, `sync-identity.ts:computeVaultId`, `chirp-sync-vault-adapter.ts` per-entry HKDF). - The sync coordinator refuses to sync across **different `vaultId`s** (`chirp-sync-coordinator.ts` SYNC_HELLO check). So two devices must share the *exact* root key, or nothing syncs. - **Recovery words encode only `rootKey[0..16]` (128-bit)** and merely derive the key that *unwraps* the stored `recovery_wrapped_root_key` blob (`recovery-words.ts:23-26`). Words alone cannot reconstruct the root key on a device that has no vault blob. - Import/restore (`RESTORE_ENTRIES`) deliberately mints a **new** root key → new `vaultId` → does not sync. That's why "import a backup" is not adoption. **Conclusion:** adoption = move the existing root key to the new device. Once it has the key, the existing sync transport fills the entries — we do **not** re-transfer entries inline. --- ## 2. Roles & terminology | Term | Meaning | |---|---| | **Source** | Existing device, vault unlocked, holds the root key. Grants adoption. | | **Receiver** | Brand-new (vault-less) device. Requests adoption, receives the root key, stands up a local vault around it. | | **Root key** | 32 random bytes; the secret everything derives from. Crown jewels. | | **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. | The channel is **device↔device chirp** (same-room audio + Noise NK). The desktop helper (peach-sync) is **not** an adoption source — it stores opaque entry blobs and never holds the root key. (Post-adoption, the helper is still a valid *entry* source because the receiver can now decrypt its blobs.) --- ## 3. UX flow **Receiver (new device):** 1. Onboarding gains a third path beside "Create vault" / "Restore from backup": **"I already have Peach on another device."** 2. Receiver sets a **local password** (wraps the adopted key on this device) and confirms it — same strength gate as create-vault. 3. Receiver enters adoption-pairing (chirp, same room). Shows the SAS. 4. On success: "Joined 's vault" → vault opens, sync begins filling entries. **Source (existing device):** 1. While unlocked, a peer requests adoption → modal: **"A new device wants to join this vault. Verify the code matches: `1234`. Allowing this copies your entire vault and Pro to that device."** 2. The SAS confirmation **is** the consent gate. Reject → abort, nothing sent. --- ## 4. Protocol Built on the existing chirp stack: chirp ephemeral exchange → Noise NK → shared handshake hash → SAS. All adoption frames travel **inside the Noise transport** (ChaCha20-Poly1305) and are exchanged **only after both sides confirm the SAS**. The receiver is vault-less, so it has **no long-term identity** to put in a normal `PAIR_IDENTITY`. Adoption is therefore an asymmetric sub-protocol the receiver signals by sending `ADOPT_REQUEST` *instead of* `PAIR_IDENTITY`. ### Frames (JSON, snake_case, browser/Android parity) ``` ADOPT_REQUEST { type:"ADOPT_REQUEST", device_id, device_name, device_type } ADOPT_GRANT { type:"ADOPT_GRANT", root_key_hex, // 64 lowercase hex chars (32 bytes) vault_id, // for receiver sanity-check license?, // signed PPK- blob, if source is Pro source: { device_id, device_name, device_type, long_term_public_key_hex, signing_public_key_hex, ws_port? } } ADOPT_IDENTITY { type:"ADOPT_IDENTITY", device_id, device_name, device_type, long_term_public_key_hex, signing_public_key_hex } ``` ### Sequence (after Noise NK established + SAS confirmed on BOTH) ``` Receiver → Source : ADOPT_REQUEST (receiver is blank; no keys yet) Source shows consent modal (SAS doubles as approval). On reject → close, abort. Source → Receiver: ADOPT_GRANT (root key + source identity + license?) Receiver: - assert root_key_hex is 64 hex chars - createVaultFromRootKey(localPassword, root_key_hex) [new vault.ts fn] - assert computeVaultId(rootKey) == vault_id [sanity] - if license present and we're not Pro → validate+store (existing path) - upsert `source` as a paired device (trustSource:"direct") - now the receiver HAS an identity (derived from the adopted root key) Receiver → Source : ADOPT_IDENTITY (receiver's now-derivable identity) Source: upsert receiver as a paired device Both: trigger a sync round → entries flow over the existing transport. ``` If the receiver aborts before `createVaultFromRootKey`, it remains vault-less. The source never persists anything until it receives `ADOPT_IDENTITY`. ### Recovery words after adoption `createVaultFromRootKey` regenerates the recovery escrow from the adopted key, so the 12 recovery words are **identical** to the source's (they derive from `rootKey[0..16]`). Recovery is consistent across all devices on the vault. The receiver's *local password* is independent. --- ## 5. Threat model What changes vs. today's pairing (which only swaps public keys): - **Asset on the wire:** the 32-byte root key (full vault control), not just a sync link. Protected by (a) Noise NK transport encryption, (b) the user-confirmed **SAS** out-of-band check, (c) explicit source-side consent copy that names the consequence. - **Residual risk:** an in-room MITM who completes the handshake gets the root key *iff the user mis-confirms the SAS*. Same ~1e-6 SAS bound as pairing, but higher blast radius. Mitigations to decide (§8): stronger consent on the source; optional source re-auth (re-enter master password) before `ADOPT_GRANT`. - **No plaintext at rest in transit:** root key handled as transient bytes, **zeroed** after `createVaultFromRootKey`; never logged; never written except as the wrapped blobs the vault already uses. - **Sanity gates:** receiver verifies 32-byte length and that `computeVaultId(rootKey) === grant.vault_id` before committing. - **Downgrade/abuse:** `ADOPT_*` are new frames; a pre-adoption peer that receives `ADOPT_REQUEST` errors out (can't be tricked into granting). Only updated source devices can grant. --- ## 6. Vault-less execution model (browser) The chirp orchestrator runs in the **popup** (needs `getUserMedia`); the background owns the vault. Adoption adds background handlers that **do not require an unlocked vault**: - `CREATE_VAULT_FROM_ROOT_KEY { password, rootKeyHex }` → `vault.createVaultFromRootKey(...)`. Vault-less-allowed (it *creates* the vault). Returns ok + computed vaultId. - The popup receives `ADOPT_GRANT` over the channel, hands `rootKeyHex` to the bg via that message, then proceeds to identity exchange + sync. - Source side: a normal (unlocked) device's pairing entry point must recognize an inbound `ADOPT_REQUEST` and run the grant path (with the consent gate) instead of the normal identity exchange. The root key crosses **popup→background** via runtime messaging (in-process, same extension) — acceptable; it's the same boundary `CREATE_VAULT` already uses for the password. --- ## 7. Cross-platform contract | Repo | Work | |---|---| | **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. | | **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). | | **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.) | Frames are additive and snake_case so the two app codebases interoperate; the SAS KAT and Noise KATs already pin cross-impl agreement. --- ## 8. Open decisions (need sign-off before build) 1. **Source re-auth before granting?** Require the source user to re-enter their master password (or biometric) in the consent modal before `ADOPT_GRANT`? *Recommendation: yes* — the SAS proves channel integrity but not that the person at the source consents to exporting the whole vault. 2. **Receiver password timing:** set the local password *before* the SAS (so success is one tap) or *after* a confirmed grant? *Recommendation: before* — keeps the post-SAS path non-interactive and avoids holding the root key in memory while prompting. 3. **Same-room only (chirp) for v1**, with helper-relay rendezvous as a later add? *Recommendation: yes, chirp-only v1.* --- ## 9. Implementation map (file references) **Vault / keys (peach-browser):** - `src/background/vault.ts` — `createVault` (965-1007), `generateAndWrapRootKey` (301-342), `wrapRootKeyForDerivedKey`, `unwrapRootKeyForRecovery` (349-381), `getRootKeyHex` (1456-1458). **New:** `createVaultFromRootKey(password, rootKeyHex)` — mirror `createVault` but take the root key as input instead of generating it. - `src/background/messages.ts` — `CREATE_VAULT` (285-299), `RECOVER_WITH_WORDS` (364-399), sync handlers (1318-1407). **New:** `CREATE_VAULT_FROM_ROOT_KEY`. - `src/shared/sync-identity.ts` — `computeVaultId` (75-82). - `src/shared/recovery-words.ts` — escrow encoding (23-62). **Pairing (peach-browser):** - `src/shared/chirp-handshake.ts` — `LocalIdentity`/`PairingFrame` (49-87), initiator/responder, `derivePairingSAS`. **New:** adoption frames + a receiver/source path that swaps the identity exchange for `ADOPT_*`. - `src/popup/chirp-pair.ts` — orchestrator, `gateOnSAS`. **New:** adoption deps (`requestAdoption`, `grantAdoption`, `createVaultFromRootKey`). **Onboarding (peach-browser):** - `src/popup/views/Onboarding.tsx`, `src/popup/views/Lock.tsx` (create + restore footer), `src/popup/App.tsx` (view state machine). **New:** "another device" entry + adopt view. **Android mirror:** `ChirpPairing.kt`, `ChirpIdentity.kt`, `ui/pairing/ChirpPairViewModel.kt`, `VaultRepository`, onboarding. --- ## 10. Test plan - `createVaultFromRootKey` round-trip: unlock with the local password yields the same root key; `computeVaultId` equals the source's. - Recovery words from an adopted vault equal the source's words. - Adoption handshake KAT: receiver obtains the root key; mutual identity exchange registers both devices; `vault_id` sanity check passes. - SAS-reject / consent-reject aborts **before** any `ADOPT_GRANT` is sent and the receiver stays vault-less. - Cross-impl: an Android source ↔ browser receiver (and vice-versa) agree on the SAS and the frame shapes (extend existing cross-platform KATs). --- ## 11. Build sequencing (once decisions in §8 are settled) 1. **vault.ts `createVaultFromRootKey` + `CREATE_VAULT_FROM_ROOT_KEY`** (+ tests) — pure, testable, no UI. 2. **Adoption sub-protocol** in `chirp-handshake.ts`/`chirp-pair.ts` (+ handshake KAT). 3. **Onboarding entry + adopt view + source consent UI.** 4. **Android mirror.** 5. Version bumps per repo; full test + build; coordinated promotion (browser + Android land together, like the license-transfer change).