vault-adoption-design.md
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
vaultIdall derive from the 32-byte root key (vault.ts:getRootKeyHex,sync-identity.ts:computeVaultId,chirp-sync-vault-adapter.tsper-entry HKDF). - The sync coordinator refuses to sync across different
vaultIds (chirp-sync-coordinator.tsSYNC_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 storedrecovery_wrapped_root_keyblob (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 → newvaultId→ 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):
- Onboarding gains a third path beside "Create vault" / "Restore from backup": "I already have Peach on another device."
- Receiver sets a local password (wraps the adopted key on this device) and confirms it — same strength gate as create-vault.
- Receiver enters adoption-pairing (chirp, same room). Shows the SAS.
- On success: "Joined <Source>'s vault" → vault opens, sync begins filling entries.
Source (existing device):
- 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." - 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_idbefore committing. - Downgrade/abuse:
ADOPT_*are new frames; a pre-adoption peer that receivesADOPT_REQUESTerrors 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_GRANTover the channel, handsrootKeyHexto 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_REQUESTand 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)
- 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. - 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.
- 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)— mirrorcreateVaultbut 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 forADOPT_*.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
createVaultFromRootKeyround-trip: unlock with the local password yields the same root key;computeVaultIdequals 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_idsanity check passes. - SAS-reject / consent-reject aborts before any
ADOPT_GRANTis 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)
- vault.ts
createVaultFromRootKey+CREATE_VAULT_FROM_ROOT_KEY(+ tests) — pure, testable, no UI. - Adoption sub-protocol in
chirp-handshake.ts/chirp-pair.ts(+ handshake KAT). - Onboarding entry + adopt view + source consent UI.
- Android mirror.
- Version bumps per repo; full test + build; coordinated promotion (browser + Android land together, like the license-transfer change).