vault-adoption-design.md

12.2 KB · raw

rendered · source

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

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 <Source>'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):


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:

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

Pairing (peach-browser):

Onboarding (peach-browser):

Android mirror: ChirpPairing.kt, ChirpIdentity.kt, ui/pairing/ChirpPairViewModel.kt, VaultRepository, onboarding.


10. Test plan


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