444d98d8f3f4

feat: implement frozen Adoption v2 transcript and pending identity recovery (v5.2.38)

time
2026-09-10 21:04 UTC · 1 month ago
machine
koh2
session
cross-client remediation
parent
4e04c3621ecd
9 files package.json+1 −1scripts/check-cross-client-sync.mjs+31 −1src/background/device-identity.ts+12 −0src/background/pending-adoption-holder.test.ts+22 −1src/background/pending-adoption-holder.ts+46 −4src/background/relay-adoption-bg.test.ts+67 −107src/background/relay-adoption-bg.ts+40 −20src/shared/adoption-protocol.test.ts+119 −0src/shared/adoption-protocol.ts+350 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.37",3+  "version": "5.2.38",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/check-cross-client-sync.mjs+31 −1
@@ -15,6 +15,11 @@1515 const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");1616 const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");1717 const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");18+const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");19+const androidAdoption = [20+  "AdoptionProtocol.kt",21+  "VaultAdopt.kt",22+].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");1823 1924 // Keep the source guards as cheap secondary checks. The real gate below crosses2025 // a process boundary and exercises each production coordinator and JSON codec.@@ -54,6 +59,24 @@5459 if (browserProtocolVersion !== androidProtocolVersion) {5560   throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);5661 }62+if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {63+  throw new Error("Browser and Android adoption transcript versions differ");64+}65+for (const frame of [66+  "ADOPT_REQUEST_V2",67+  "ADOPT_CONFIRM_REQUIRED",68+  "ADOPT_CONFIRM",69+  "ADOPT_PREPARE",70+  "ADOPT_READY",71+  "ADOPT_GRANT",72+  "ADOPT_APPLIED",73+  "ADOPT_COMPLETE",74+  "ADOPT_REJECT",75+]) {76+  if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {77+    throw new Error(`A client lacks adoption v2 frame ${frame}`);78+  }79+}5780 // Both clients must require exact equality, never a "greater-or-equal" pass.5881 if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||5982     androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {@@ -220,6 +243,7 @@220243 221244   let first;222245   let second;246+  let timeoutId;223247   try {224248     if (initiator === "android") {225249       first = launchAndroid("initiator");@@ -230,7 +254,12 @@230254       const port = await waitForFile(browserPortFile, first);231255       second = launchAndroid("responder", port);232256     }233-    const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`cross-client ${initiator} round timed out after 120s`)), 120_000));257+    const timeout = new Promise((_, reject) => {258+      timeoutId = setTimeout(259+        () => reject(new Error(`cross-client ${initiator} round timed out after 120s`)),260+        120_000,261+      );262+    });234263     const results = await Promise.race([235264       Promise.all([waitForExit(first), waitForExit(second)]),236265       timeout,@@ -244,6 +273,7 @@244273     const details = [first, second].filter(Boolean).map((child) => child.diagnostics).map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");245274     throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });246275   } finally {276+    clearTimeout(timeoutId);247277     terminate(first);248278     terminate(second);249279   }

view file

src/background/device-identity.ts+12 −0
@@ -409,6 +409,18 @@409409   };410410 }411411 412+export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {413+  const record = await makeRecord();414+  const ik = crypto.getRandomValues(new Uint8Array(32));415+  try {416+    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));417+    await cacheSet(record);418+    return await readPreVaultIdentity(password);419+  } finally {420+    ik.fill(0);421+  }422+}423+412424 export async function getOrCreateDeviceId(): Promise<string> {413425   if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");414426   return (await ensureRecord()).deviceId;

view file

src/background/pending-adoption-holder.test.ts+22 −1
@@ -1,7 +1,9 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 import {33   listPendingAdoptionHolders,4+  persistPendingAdoptionIdentity,45   persistPendingAdoptionHolder,6+  readPendingAdoptionIdentity,57   readPendingAdoptionHolder,68   removePendingAdoptionHolder,79 } from "./pending-adoption-holder";@@ -16,7 +18,10 @@1618 const request = { type: "ADOPT_REQUEST", device_id: "phone", device_name: "Phone", device_type: "android" } as const;1719 1820 describe("pending adoption holder", () => {19-  beforeEach(() => { delete data.peach_pending_adoption_holder_v1; });21+  beforeEach(() => {22+    delete data.peach_pending_adoption_holder_v1;23+    delete data.peach_pending_adoption_identity_v2;24+  });2025 2126   it("persists and lists a typed candidate across reads", async () => {2227     await persistPendingAdoptionHolder(request);@@ -27,6 +32,22 @@2732 2833   it("removes an explicitly revoked candidate", async () => {2934     await persistPendingAdoptionHolder(request);35+    await removePendingAdoptionHolder();36+    expect(await listPendingAdoptionHolders()).toEqual([]);37+  });38+39+  it("lists and explicitly revokes a v2 pending identity", async () => {40+    await persistPendingAdoptionIdentity({41+      deviceId: "phone-v2",42+      deviceName: "Phone v2",43+      deviceType: "android",44+      longTermPublicKeyHex: "ab".repeat(32),45+      trustSource: "direct",46+      pairedAt: 123,47+    }, 123);48+49+    expect((await readPendingAdoptionIdentity())?.device.deviceId).toBe("phone-v2");50+    expect(await listPendingAdoptionHolders()).toHaveLength(1);3051     await removePendingAdoptionHolder();3152     expect(await listPendingAdoptionHolders()).toEqual([]);3253   });

view file

src/background/pending-adoption-holder.ts+46 −4
@@ -1,13 +1,20 @@11 import type { AdoptRequest } from "../shared/vault-adopt";2+import type { PairedDevice } from "../shared/sync-types";23 import { storageGet, storageRemove, storageSet } from "../shared/storage";34 45 export const PENDING_ADOPTION_HOLDER_KEY = "peach_pending_adoption_holder_v1";6+export const PENDING_ADOPTION_IDENTITY_KEY = "peach_pending_adoption_identity_v2";57 68 export type PendingAdoptionHolder = {79   request: AdoptRequest;810   createdAt: number;911 };1012 13+export type PendingAdoptionIdentity = {14+  device: PairedDevice;15+  createdAt: number;16+};17+1118 function isHolder(value: unknown): value is PendingAdoptionHolder {1219   if (!value || typeof value !== "object") return false;1320   const holder = value as Partial<PendingAdoptionHolder>;@@ -32,13 +39,48 @@3239   }3340 }3441 35-export async function listPendingAdoptionHolders(): Promise<PendingAdoptionHolder[]> {36-  const holder = await readPendingAdoptionHolder();37-  return holder ? [holder] : [];42+export async function listPendingAdoptionHolders(): Promise<Array<PendingAdoptionHolder | PendingAdoptionIdentity>> {43+  const [holder, identity] = await Promise.all([44+    readPendingAdoptionHolder(),45+    readPendingAdoptionIdentity(),46+  ]);47+  return [holder, identity].filter((value): value is PendingAdoptionHolder | PendingAdoptionIdentity => value !== null);3848 }3949 4050 export async function removePendingAdoptionHolder(): Promise<void> {41-  await storageRemove(PENDING_ADOPTION_HOLDER_KEY);51+  await Promise.all([52+    storageRemove(PENDING_ADOPTION_HOLDER_KEY),53+    storageRemove(PENDING_ADOPTION_IDENTITY_KEY),54+  ]);4255 }4356 4457 export const revokePendingAdoptionHolder = removePendingAdoptionHolder;58+59+export async function persistPendingAdoptionIdentity(60+  device: PairedDevice,61+  createdAt: number,62+): Promise<boolean> {63+  await storageSet(PENDING_ADOPTION_IDENTITY_KEY, JSON.stringify({ device, createdAt }));64+  return true;65+}66+67+export async function readPendingAdoptionIdentity(): Promise<PendingAdoptionIdentity | null> {68+  const raw = await storageGet(PENDING_ADOPTION_IDENTITY_KEY);69+  if (!raw) return null;70+  try {71+    const value = JSON.parse(raw) as { device?: PairedDevice; createdAt?: number };72+    return value.device?.deviceId && Number.isFinite(value.createdAt)73+      ? { device: value.device, createdAt: value.createdAt as number }74+      : null;75+  } catch {76+    return null;77+  }78+}79+80+export async function completePendingAdoptionIdentity(device: PairedDevice): Promise<void> {81+  const pending = await readPendingAdoptionIdentity();82+  if (pending?.device.deviceId === device.deviceId &&83+      pending.device.longTermPublicKeyHex === device.longTermPublicKeyHex) {84+    await storageRemove(PENDING_ADOPTION_IDENTITY_KEY);85+  }86+}

view file

src/background/relay-adoption-bg.test.ts+67 −107
@@ -66,6 +66,9 @@6666   }),6767   getDeviceName: mocks.getDeviceName,6868   buildLocalIdentity: mocks.localIdentity,69+  preparePreVaultIdentity: vi.fn(async () => undefined),70+  readPreVaultIdentity: mocks.localIdentity,71+  rotatePreVaultIdentity: mocks.localIdentity,6972   rotateDeviceId: mocks.rotateDeviceId,7073 }));7174 @@ -153,6 +156,41 @@153156 const encode = (value: unknown) =>154157   new TextEncoder().encode(JSON.stringify(value));155158 159+function adoptionV2Frames(options: {160+  rootKeyHex: string;161+  vaultId: string;162+  deviceId?: string;163+  deviceName?: string;164+  longTermPublicKeyHex?: string;165+  signingPublicKeyHex?: string;166+  license?: string;167+  snapshotCapable?: boolean;168+}): unknown[] {169+  const license = options.license ?? "PPK-test";170+  return [171+    { type: "ADOPT_CONFIRM_REQUIRED" },172+    {173+      type: "ADOPT_PREPARE",174+      device_id: options.deviceId ?? "phone-source-id",175+      device_name: options.deviceName ?? "June's phone",176+      device_type: "android",177+      long_term_public_key_hex: options.longTermPublicKeyHex ?? "99".repeat(32),178+      signing_public_key_hex: options.signingPublicKeyHex ?? "aa".repeat(32),179+      vault_id: options.vaultId,180+      relay_url: "wss://relay.peachpasswords.com",181+      license,182+      snapshot_capable: options.snapshotCapable,183+    },184+    {185+      type: "ADOPT_GRANT",186+      root_key_hex: options.rootKeyHex,187+      vault_id: options.vaultId,188+      license,189+    },190+    { type: "ADOPT_COMPLETE" },191+  ];192+}193+156194 function socketWithFrames(157195   frames: unknown[],158196   options: { closeOnEmpty?: boolean } = {},@@ -702,6 +740,7 @@702740   it("surfaces the Pro-required rejection without creating a vault", async () => {703741     mocks.openSocket.mockResolvedValue(socketWithFrames([704742       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },743+      { type: "ADOPT_CONFIRM_REQUIRED" },705744       { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },706745     ]));707746 @@ -727,19 +766,7 @@727766     const vaultId = "b".repeat(64);728767     const socket = socketWithFrames([729768       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },730-      {731-        type: "ADOPT_GRANT",732-        root_key_hex: rootKeyHex,733-        vault_id: vaultId,734-        license: "PPK-test",735-        source: {736-          device_id: "phone-source-id",737-          device_name: "June's phone",738-          device_type: "android",739-          long_term_public_key_hex: "99".repeat(32),740-        },741-      },742-      { type: "ADOPT_COMPLETE" },769+      ...adoptionV2Frames({ rootKeyHex, vaultId }),743770     ], { closeOnEmpty: true });744771     mocks.openSocket.mockResolvedValue(socket);745772     mocks.createVault.mockResolvedValue({ vaultId });@@ -778,7 +805,7 @@778805       });779806     // ADOPT_REQUEST is the first adoption-protocol frame out of this side;780807     // ADOPT_GRANT is the source side's frame and must never appear here.781-    expect(sentTypes).toContain("ADOPT_REQUEST");808+    expect(sentTypes).toContain("ADOPT_REQUEST_V2");782809     expect(sentTypes).not.toContain("ADOPT_GRANT");783810   });784811 @@ -794,23 +821,7 @@794821     const vaultId = "b".repeat(64);795822     const frames = [796823       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },797-      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two798-      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.799-      {800-        type: "ADOPT_GRANT",801-        root_key_hex: rootKeyHex,802-        vault_id: vaultId,803-        license: "PPK-test",804-        source: {805-          device_id: "phone-source-id",806-          device_name: "June's phone",807-          device_type: "android",808-          long_term_public_key_hex: "99".repeat(32),809-          signing_public_key_hex: "aa".repeat(32),810-          relay_url: "wss://relay.peachpasswords.com",811-        },812-      },813-      { type: "ADOPT_COMPLETE" },824+      ...adoptionV2Frames({ rootKeyHex, vaultId }),814825     ];815826     mocks.createVault.mockResolvedValue({ vaultId });816827     mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));@@ -893,18 +904,7 @@893904     mocks.isProUser.mockResolvedValue(false);894905     mocks.openSocket.mockResolvedValue(socketWithFrames([895906       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },896-      {897-        type: "ADOPT_GRANT",898-        root_key_hex: rootKeyHex,899-        vault_id: vaultId,900-        license: "PPK-invalid",901-        source: {902-          device_id: "phone-source-id",903-          device_name: "June's phone",904-          device_type: "android",905-          long_term_public_key_hex: "99".repeat(32),906-        },907-      },907+      ...adoptionV2Frames({ rootKeyHex, vaultId, license: "PPK-invalid" }),908908     ]));909909 910910     const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");@@ -912,7 +912,7 @@912912     await waitForPush("ADOPTION_SOURCE_RESULT");913913 914914     expect(mocks.rollbackVault).toHaveBeenCalledOnce();915-    expect(mocks.localIdentity).not.toHaveBeenCalled();915+    expect(mocks.localIdentity).toHaveBeenCalled();916916     expect(mocks.upsert).not.toHaveBeenCalled();917917     expect(mocks.pushes).toContainEqual(expect.objectContaining({918918       type: "ADOPTION_SOURCE_RESULT",@@ -927,21 +927,10 @@927927     const rootKeyHex = "f0".repeat(32);928928     const vaultId = "b".repeat(64);929929     mocks.createVault.mockResolvedValue({ vaultId });930-    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));930+    mocks.upsert.mockRejectedValue(new Error("pair_persist_failed"));931931     mocks.openSocket.mockResolvedValue(socketWithFrames([932932       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },933-      {934-        type: "ADOPT_GRANT",935-        root_key_hex: rootKeyHex,936-        vault_id: vaultId,937-        license: "PPK-test",938-        source: {939-          device_id: "phone-source-id",940-          device_name: "June's phone",941-          device_type: "android",942-          long_term_public_key_hex: "99".repeat(32),943-        },944-      },933+      ...adoptionV2Frames({ rootKeyHex, vaultId }),945934     ]));946935 947936     const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");@@ -949,10 +938,10 @@949938     await waitForPush("ADOPTION_SOURCE_RESULT");950939 951940     expect(mocks.rollbackVault).toHaveBeenCalledOnce();952-    expect(mocks.upsert).not.toHaveBeenCalled();941+    expect(mocks.upsert).toHaveBeenCalledOnce();953942     expect(mocks.pushes).toContainEqual(expect.objectContaining({954943       type: "ADOPTION_SOURCE_RESULT",955-      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),944+      payload: expect.objectContaining({ status: "error", error: "pair_persist_failed" }),956945     }));957946   });958947 @@ -965,20 +954,12 @@965954     mocks.createVault.mockResolvedValue({ vaultId });966955     mocks.openSocket.mockResolvedValue(socketWithFrames([967956       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },968-      {969-        type: "ADOPT_GRANT",970-        root_key_hex: rootKeyHex,971-        vault_id: vaultId,972-        license: "PPK-test",973-        source: {974-          device_id: "old-phone-id",975-          device_name: "Old phone",976-          device_type: "android",977-          long_term_public_key_hex: "99".repeat(32),978-        },979-        // NO snapshot_capable: pre-C source does not echo the field980-      },981-      { type: "ADOPT_COMPLETE" },957+      ...adoptionV2Frames({958+        rootKeyHex,959+        vaultId,960+        deviceId: "old-phone-id",961+        deviceName: "Old phone",962+      }),982963     ], { closeOnEmpty: true }));983964 984965     const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");@@ -1035,24 +1016,13 @@10351016     );10361017     const socket = socketWithFrames([10371018       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1038-      {1039-        type: "ADOPT_GRANT",1040-        root_key_hex: rootKeyHex,1041-        vault_id: vaultId,1042-        license: "PPK-test",1043-        snapshot_capable: true,1044-        source: {1045-          device_id: "phone-source-id",1046-          device_name: "June's phone",1047-          device_type: "android",1048-          long_term_public_key_hex: longTermPubkeyHex,1049-          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1050-          relay_url: "wss://relay.peachpasswords.com",1051-        },1052-      },1053-      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is1054-      // an optional follow-up frame on the same channel.1055-      { type: "ADOPT_COMPLETE" },1019+      ...adoptionV2Frames({1020+        rootKeyHex,1021+        vaultId,1022+        longTermPublicKeyHex: longTermPubkeyHex,1023+        signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1024+        snapshotCapable: true,1025+      }),10561026       // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames10571027       // after the handshake; one empty chunk ends the phase.10581028       { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },@@ -1097,22 +1067,12 @@10971067     const longTermPubkeyHex = "99".repeat(32);10981068     const socket = socketWithFrames([10991069       { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1100-      {1101-        type: "ADOPT_GRANT",1102-        root_key_hex: rootKeyHex,1103-        vault_id: vaultId,1104-        license: "PPK-test",1105-        snapshot_capable: true,1106-        source: {1107-          device_id: "phone-source-id",1108-          device_name: "June's phone",1109-          device_type: "android",1110-          long_term_public_key_hex: longTermPubkeyHex,1111-          signing_public_key_hex: "aa".repeat(32),1112-          relay_url: "wss://relay.peachpasswords.com",1113-        },1114-      },1115-      { type: "ADOPT_COMPLETE" },1070+      ...adoptionV2Frames({1071+        rootKeyHex,1072+        vaultId,1073+        longTermPublicKeyHex: longTermPubkeyHex,1074+        snapshotCapable: true,1075+      }),11161076       // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot11171077       // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.11181078       { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },

view file

src/background/relay-adoption-bg.ts+40 −20
@@ -19,12 +19,18 @@1919   isAdoptSnapshotApplied,2020   receiveFrame,2121   receiveVaultSnapshot,22-  requestAdoption,2322   sendFrame,2423   sendVaultSnapshot,25-  type AdoptRequest,24+  type AdoptionSourceDeps,2625 } from "../shared/vault-adopt";2726 import {27+  grantAdoptionV2,28+  isAdoptRequestV2,29+  requestAdoptionV2,30+  type AdoptionV2ReceiverDeps,31+  type AdoptionV2SourceDeps,32+} from "../shared/adoption-protocol";33+import {2834   derivePairingSAS,2935   type PairingTransport,3036 } from "../shared/pairing-handshake";@@ -38,6 +44,9 @@3844   buildLocalIdentity,3945   getDeviceName,4046   getLocalPairingIdentity,47+  preparePreVaultIdentity,48+  readPreVaultIdentity,49+  rotatePreVaultIdentity,4150   rotateDeviceId,4251 } from "./device-identity";4352 import {@@ -54,8 +63,10 @@5463 import { storageRemove, storageSet } from "../shared/storage";5564 import {5665   persistPendingAdoptionHolder,66+  persistPendingAdoptionIdentity,5767   readPendingAdoptionHolder,5868   removePendingAdoptionHolder,69+  completePendingAdoptionIdentity,5970 } from "./pending-adoption-holder";6071 import { isValidPairSecret, setPairSecret } from "./pair-secrets";6172 import { createSyncVaultAdapter } from "./sync-vault-adapter";@@ -178,6 +189,7 @@178189   }179190   inFlight = true;180191   try {192+    await preparePreVaultIdentity(password);181193     const settings = await getSyncSettings();182194     const relayUrl = relayInForce(settings);183195     const token = generatePairingRendezvousToken();@@ -261,9 +273,11 @@261273       async receive() { return session.decrypt(await socket.receive()); },262274     };263275     const request = await receiveFrame(channel);264-    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");276+    if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {277+      throw new Error("expected_ADOPT_REQUEST");278+    }265279 266-    const device = await grantAdoption(channel, request, {280+    const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {267281       sourceIdentity: local.identity,268282       pairState: {269283         rendezvousSecretHex: bytesToHex(@@ -315,7 +329,15 @@315329         await rotateDeviceId();316330         return await buildLocalIdentity();317331       },318-    });332+      persistPendingIdentity: persistPendingAdoptionIdentity,333+      async completePendingIdentity(paired: PairedDevice) {334+        await completePendingAdoptionIdentity(paired);335+      },336+      snapshotCapable: () => true,337+    };338+    const device = isAdoptRequestV2(request)339+      ? await grantAdoptionV2(channel, request, sourceDeps)340+      : await grantAdoption(channel, request, sourceDeps);319341     if (!device) {320342       push("ADOPTION_SOURCE_RESULT", {321343         status: "error",@@ -435,9 +457,8 @@435457 436458     const deviceName = await getDeviceName();437459 438-    const adoptionResult = await requestAdoption(channel, {460+    const receiverDeps: AdoptionV2ReceiverDeps = {439461       deviceName,440-      generateDeviceId: () => crypto.randomUUID(),441462       async confirmSas() {442463         const sas = derivePairingSAS(session.handshakeHash);443464         const id = crypto.randomUUID();@@ -461,18 +482,9 @@461482       async license() {462483         return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;463484       },464-      async deriveLocalIdentity() {465-        // Direct call for the same reason as applyLicense above.466-        return await buildLocalIdentity();467-      },468485       async rotateLocalIdentity() {469-        await rotateDeviceId();470-        return await buildLocalIdentity();486+        return await rotatePreVaultIdentity(password);471487       },472-      async upsertPairedDevice(paired) {473-        // Direct call for the same reason as applyLicense above.474-        await storeAdoptedDevice(paired);475-      },476488       async hasSourceIdentityCollision(source) {477489         return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);478490       },@@ -483,7 +495,15 @@483495       onError(error) {484496         protocolError = error;485497       },486-    });498+      async localIdentity() {499+        return await readPreVaultIdentity(password);500+      },501+      async validateSource() {},502+      async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {503+        await storeAdoptedDevice(paired);504+      },505+    };506+    const adoptionResult = await requestAdoptionV2(channel, receiverDeps);487507 488508     if (!adoptionResult) {489509       if (createdVault) {@@ -640,7 +660,7 @@640660 641661 function waitForSourceSasConfirmation(642662   id: string,643-  request: AdoptRequest,663+  request: { device_name: string },644664   sas: string,645665 ): Promise<boolean> {646666   return new Promise<boolean>((resolve) => {@@ -669,7 +689,7 @@669689  * the root key until the receiver returns ADOPT_CONFIRM(true).670690  */671691 function requestConsent(672-  request: AdoptRequest,692+  request: { device_name: string },673693   sas: string,674694   id: string,675695 ): Promise<boolean> {

view file

src/shared/adoption-protocol.test.ts+119 −0
@@ -0,0 +1,119 @@1+import { describe, expect, it, vi } from "vitest";2+import type { PairingTransport } from "./pairing-handshake";3+import { computeVaultId } from "./sync-identity";4+import {5+  ADOPTION_VERSION,6+  type AdoptionV2ReceiverDeps,7+  type AdoptionV2SourceDeps,8+  grantAdoptionV2,9+  isAdoptRequestV2,10+  requestAdoptionV2,11+} from "./adoption-protocol";12+13+function memoryPair(): [PairingTransport, PairingTransport] {14+  const left: Uint8Array[] = [];15+  const right: Uint8Array[] = [];16+  const leftReaders: Array<(value: Uint8Array) => void> = [];17+  const rightReaders: Array<(value: Uint8Array) => void> = [];18+  const make = (19+    inbox: Uint8Array[],20+    outbox: Uint8Array[],21+    readers: Array<(value: Uint8Array) => void>,22+    outReaders: Array<(value: Uint8Array) => void>,23+  ): PairingTransport => ({24+    async send(message) {25+      const reader = outReaders.shift();26+      if (reader) reader(message);27+      else outbox.push(message);28+    },29+    receive() {30+      const message = inbox.shift();31+      return message ? Promise.resolve(message) : new Promise((resolve) => readers.push(resolve));32+    },33+  });34+  return [35+    make(left, right, leftReaders, rightReaders),36+    make(right, left, rightReaders, leftReaders),37+  ];38+}39+40+function identity(name: string) {41+  return {42+    deviceId: `${name}-id`,43+    deviceName: name,44+    deviceType: "extension" as const,45+    longTermPublicKeyHex: "ab".repeat(32),46+    signingPublicKeyHex: "cd".repeat(32),47+  };48+}49+50+describe("adoption transcript v2", () => {51+  it("persists the validated receiver before reading or sending the root", async () => {52+    const [receiverChannel, sourceChannel] = memoryPair();53+    const rootKeyHex = "11".repeat(32);54+    const vaultId = await computeVaultId(55+      Uint8Array.from({ length: 32 }, () => 0x11),56+    );57+    const order: string[] = [];58+    const receiverDeps: AdoptionV2ReceiverDeps = {59+      deviceName: "receiver",60+      localIdentity: vi.fn(async () => identity("receiver")),61+      rotateLocalIdentity: vi.fn(async () => identity("receiver-rotated")),62+      license: vi.fn(async () => "PPK-receiver"),63+      confirmSas: vi.fn(async () => true),64+      validateSource: vi.fn(async () => undefined),65+      hasSourceIdentityCollision: vi.fn(async () => false),66+      createVaultFromRootKey: vi.fn(async () => {67+        order.push("create-vault");68+        return { vaultId };69+      }),70+      applyLicense: vi.fn(async () => undefined),71+      upsertPairedDeviceAndClearRevocation: vi.fn(async () => undefined),72+      onError: vi.fn(),73+    };74+    const sourceDeps: AdoptionV2SourceDeps = {75+      sourceIdentity: identity("source"),76+      rootKeyHex: vi.fn(async () => {77+        order.push("read-root");78+        return rootKeyHex;79+      }),80+      vaultId: vi.fn(async () => vaultId),81+      license: vi.fn(async () => "PPK-source"),82+      applyLicense: vi.fn(async () => undefined),83+      onConsent: vi.fn(async () => true),84+      persistPendingIdentity: vi.fn(async () => {85+        order.push("persist-pending");86+        return true;87+      }),88+      completePendingIdentity: vi.fn(async () => {89+        order.push("complete-pending");90+      }),91+      hasDeviceIdentityCollision: vi.fn(async () => false),92+      rotateSourceIdentity: vi.fn(async () => identity("source-rotated")),93+      upsertPairedDevice: vi.fn(async () => undefined),94+      snapshotCapable: () => true,95+    };96+97+    const [result] = await Promise.all([98+      requestAdoptionV2(receiverChannel, receiverDeps),99+      (async () => {100+        const request = JSON.parse(101+          new TextDecoder().decode(await sourceChannel.receive()),102+        ) as unknown;103+        expect(isAdoptRequestV2(request)).toBe(true);104+        if (isAdoptRequestV2(request)) {105+          await grantAdoptionV2(sourceChannel, request, sourceDeps);106+        }107+      })(),108+    ]);109+110+    expect(result?.device.deviceId).toBe("source-id");111+    expect(order).toEqual([112+      "persist-pending",113+      "read-root",114+      "create-vault",115+      "complete-pending",116+    ]);117+    expect(ADOPTION_VERSION).toBe(2);118+  });119+});

view file

src/shared/adoption-protocol.ts+350 −0
@@ -0,0 +1,350 @@1+import { computeVaultId } from "./sync-identity";2+import { capDeviceName } from "./sync-limits";3+import type { DeviceType, PairedDevice } from "./sync-types";4+import type { LocalIdentity, PairingTransport } from "./pairing-handshake";5+import { receiveFrame } from "./vault-adopt";6+7+export const ADOPTION_VERSION = 2;8+export type AdoptionRejectReason = "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";9+export interface AdoptRequestV2 {10+  type: "ADOPT_REQUEST_V2";11+  adoptionVersion: 2;12+  device_id: string;13+  device_name: string;14+  device_type: DeviceType;15+  long_term_public_key_hex: string;16+  signing_public_key_hex: string;17+  license?: string;18+  snapshot_capable?: boolean;19+}20+export interface AdoptConfirmRequired {21+  type: "ADOPT_CONFIRM_REQUIRED";22+}23+export interface AdoptConfirm {24+  type: "ADOPT_CONFIRM";25+  confirmed: boolean;26+}27+export interface AdoptPrepare {28+  type: "ADOPT_PREPARE";29+  device_id: string;30+  device_name: string;31+  device_type: DeviceType;32+  long_term_public_key_hex: string;33+  signing_public_key_hex: string;34+  vault_id: string;35+  ws_port?: number;36+  relay_url?: string;37+  license?: string;38+  snapshot_capable?: boolean;39+}40+export interface AdoptReady {41+  type: "ADOPT_READY";42+}43+export interface AdoptGrantV2 {44+  type: "ADOPT_GRANT";45+  root_key_hex: string;46+  vault_id: string;47+  license?: string;48+}49+export interface AdoptApplied {50+  type: "ADOPT_APPLIED";51+}52+export interface AdoptComplete {53+  type: "ADOPT_COMPLETE";54+}55+export interface AdoptRejectV2 {56+  type: "ADOPT_REJECT";57+  reason: AdoptionRejectReason;58+}59+60+const hex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);61+const device = (v: unknown): v is DeviceType => v === "extension" || v === "android" || v === "ios";62+const relay = (v: unknown): v is string => {63+  if (typeof v !== "string") return false;64+  try {65+    return new URL(v).protocol === "wss:";66+  } catch {67+    return false;68+  }69+};70+export function isAdoptRequestV2(v: unknown): v is AdoptRequestV2 {71+  const x = v as Partial<AdoptRequestV2> | null;72+  return !!x && x.type === "ADOPT_REQUEST_V2" && x.adoptionVersion === 2 &&73+    typeof x.device_id === "string" && x.device_id.length > 0 &&74+    typeof x.device_name === "string" && device(x.device_type) &&75+    hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) &&76+    (x.license === undefined || typeof x.license === "string") &&77+    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");78+}79+export function isAdoptPrepare(v: unknown): v is AdoptPrepare {80+  const x = v as Partial<AdoptPrepare> | null;81+  return !!x && x.type === "ADOPT_PREPARE" && typeof x.device_id === "string" &&82+    x.device_id.length > 0 &&83+    typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) &&84+    hex(x.signing_public_key_hex) && hex(x.vault_id) &&85+    (x.ws_port === undefined ||86+      (Number.isInteger(x.ws_port) && (x.ws_port as number) >= 1 && (x.ws_port as number) <= 65_535)) &&87+    (x.relay_url === undefined || relay(x.relay_url)) &&88+    (x.license === undefined || typeof x.license === "string") &&89+    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");90+}91+export function isAdoptRejectV2(v: unknown): v is AdoptRejectV2 {92+  const x = v as Partial<AdoptRejectV2> | null;93+  return !!x && x.type === "ADOPT_REJECT" &&94+    (x.reason === "ADOPT_PRO_REQUIRED" || x.reason === "ADOPT_DEVICE_ID_COLLISION");95+}96+97+export interface AdoptionV2ReceiverDeps {98+  deviceName: string;99+  localIdentity(): Promise<LocalIdentity>;100+  rotateLocalIdentity(): Promise<LocalIdentity>;101+  license(): Promise<string | undefined>;102+  confirmSas(): Promise<boolean>;103+  validateSource(prepare: AdoptPrepare): Promise<void>;104+  hasSourceIdentityCollision(prepare: AdoptPrepare): Promise<boolean>;105+  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;106+  applyLicense(license: string): Promise<void>;107+  upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void>;108+  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };109+  onError(error: string): void;110+}111+export interface AdoptionV2SourceDeps {112+  sourceIdentity: LocalIdentity;113+  rootKeyHex(): Promise<string>;114+  vaultId(): Promise<string>;115+  license(): Promise<string | undefined>;116+  applyLicense(license: string): Promise<void>;117+  onConsent(request: AdoptRequestV2): Promise<boolean>;118+  persistPendingIdentity(device: PairedDevice, nowMs: number): Promise<boolean>;119+  completePendingIdentity(device: PairedDevice): Promise<void>;120+  hasDeviceIdentityCollision(device: PairedDevice): Promise<boolean>;121+  rotateSourceIdentity(): Promise<LocalIdentity>;122+  upsertPairedDevice(device: PairedDevice): Promise<void>;123+  snapshotCapable(): boolean;124+  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };125+}126+127+function toDevice(128+  i: {129+    device_id: string;130+    device_name: string;131+    device_type: DeviceType;132+    long_term_public_key_hex: string;133+    signing_public_key_hex: string;134+  },135+  deps: { pairState?: { relayUrl?: string; rendezvousSecretHex?: string } },136+): PairedDevice {137+  return {138+    deviceId: i.device_id,139+    deviceName: capDeviceName(i.device_name),140+    deviceType: i.device_type,141+    longTermPublicKeyHex: i.long_term_public_key_hex,142+    signingPublicKeyHex: i.signing_public_key_hex,143+    trustSource: "direct",144+    relayUrl: deps.pairState?.relayUrl,145+    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,146+    pairedAt: Date.now(),147+  };148+}149+function requestFrame(i: LocalIdentity, license?: string): AdoptRequestV2 {150+  return {151+    type: "ADOPT_REQUEST_V2",152+    adoptionVersion: 2,153+    device_id: i.deviceId,154+    device_name: i.deviceName,155+    device_type: i.deviceType,156+    long_term_public_key_hex: i.longTermPublicKeyHex,157+    signing_public_key_hex: i.signingPublicKeyHex ?? "",158+    license,159+    snapshot_capable: true,160+  };161+}162+async function sendV2(channel: PairingTransport, frame: unknown): Promise<void> {163+  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));164+}165+function hexToBytes(hexValue: string): Uint8Array {166+  const bytes = new Uint8Array(hexValue.length / 2);167+  for (let i = 0; i < bytes.length; i++) {168+    bytes[i] = Number.parseInt(hexValue.slice(i * 2, i * 2 + 2), 16);169+  }170+  return bytes;171+}172+173+export async function requestAdoptionV2(174+  channel: PairingTransport,175+  deps: AdoptionV2ReceiverDeps,176+): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> {177+  let identity = await deps.localIdentity();178+  const ownLicense = await deps.license();179+  await sendV2(channel, requestFrame(identity, ownLicense));180+  if ((await receiveFrame(channel) as Partial<AdoptConfirmRequired> | null)?.type !== "ADOPT_CONFIRM_REQUIRED") {181+    deps.onError("ADOPT_UNEXPECTED_FRAME");182+    return null;183+  }184+  const confirmed = await deps.confirmSas();185+  await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed });186+  if (!confirmed) {187+    deps.onError("ADOPT_SAS_REJECTED");188+    return null;189+  }190+  let frame = await receiveFrame(channel);191+  if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") {192+    identity = await deps.rotateLocalIdentity();193+    await sendV2(channel, requestFrame(identity, ownLicense));194+    frame = await receiveFrame(channel);195+    if (isAdoptRejectV2(frame)) {196+      deps.onError(frame.reason);197+      return null;198+    }199+  }200+  if (isAdoptRejectV2(frame)) {201+    deps.onError(frame.reason);202+    return null;203+  }204+  if (!isAdoptPrepare(frame)) {205+    deps.onError("ADOPT_UNEXPECTED_FRAME");206+    return null;207+  }208+  await deps.validateSource(frame);209+  if (await deps.hasSourceIdentityCollision(frame)) {210+    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });211+    const retry = await receiveFrame(channel);212+    if (!isAdoptPrepare(retry) || await deps.hasSourceIdentityCollision(retry)) {213+      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });214+      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");215+      return null;216+    }217+    await deps.validateSource(retry);218+    frame = retry;219+  }220+  if (!isAdoptPrepare(frame)) {221+    deps.onError("ADOPT_UNEXPECTED_FRAME");222+    return null;223+  }224+  if (!ownLicense && !frame.license) {225+    deps.onError("ADOPT_PRO_REQUIRED");226+    return null;227+  }228+  await sendV2(channel, { type: "ADOPT_READY" });229+  const grant = await receiveFrame(channel);230+  if (!grant || (grant as AdoptGrantV2).type !== "ADOPT_GRANT") {231+    deps.onError("ADOPT_UNEXPECTED_FRAME");232+    return null;233+  }234+  const g = grant as AdoptGrantV2;235+  if (!hex(g.root_key_hex) || !hex(g.vault_id)) {236+    deps.onError("ADOPT_UNEXPECTED_FRAME");237+    return null;238+  }239+  const rootBytes = hexToBytes(g.root_key_hex);240+  let expected: string;241+  try {242+    expected = await computeVaultId(rootBytes);243+  } finally {244+    rootBytes.fill(0);245+  }246+  if (expected !== g.vault_id || g.vault_id !== frame.vault_id) {247+    deps.onError("ADOPT_VAULT_ID_MISMATCH");248+    return null;249+  }250+  const created = await deps.createVaultFromRootKey(g.root_key_hex);251+  if (created.vaultId !== g.vault_id) {252+    deps.onError("ADOPT_VAULT_ID_MISMATCH");253+    return null;254+  }255+  if (!ownLicense && g.license) await deps.applyLicense(g.license);256+  const source = toDevice(frame, deps);257+  await deps.upsertPairedDeviceAndClearRevocation(source);258+  await sendV2(channel, { type: "ADOPT_APPLIED" });259+  if (!((await receiveFrame(channel) as AdoptComplete).type === "ADOPT_COMPLETE")) {260+    deps.onError("ADOPT_COMPLETION_MISSING");261+    return null;262+  }263+  return { device: source, snapshotCapable: frame.snapshot_capable === true };264+}265+266+export async function grantAdoptionV2(267+  channel: PairingTransport,268+  request: AdoptRequestV2,269+  deps: AdoptionV2SourceDeps,270+): Promise<PairedDevice | null> {271+  let receiver = toDevice(request, deps);272+  await sendV2(channel, { type: "ADOPT_CONFIRM_REQUIRED" });273+  if (!await deps.onConsent(request)) return null;274+  const confirmation = await receiveFrame(channel);275+  if (276+    !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" ||277+    !(confirmation as AdoptConfirm).confirmed278+  ) return null;279+  if (await deps.hasDeviceIdentityCollision(receiver)) {280+    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });281+    const retry = await receiveFrame(channel);282+    if (!isAdoptRequestV2(retry)) return null;283+    receiver = toDevice(retry, deps);284+    if (await deps.hasDeviceIdentityCollision(receiver)) {285+      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });286+      return null;287+    }288+    return grantAfterIdentity(channel, retry, receiver, deps);289+  }290+  return grantAfterIdentity(channel, request, receiver, deps);291+}292+async function grantAfterIdentity(293+  channel: PairingTransport,294+  request: AdoptRequestV2,295+  receiver: PairedDevice,296+  deps: AdoptionV2SourceDeps,297+): Promise<PairedDevice | null> {298+  let license = await deps.license();299+  if (!license && request.license) {300+    try {301+      await deps.applyLicense(request.license);302+      license = await deps.license();303+    } catch {304+      license = undefined;305+    }306+  }307+  if (!license) {308+    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" });309+    return null;310+  }311+  if (!await deps.persistPendingIdentity(receiver, Date.now())) return null;312+  const root = await deps.rootKeyHex();313+  if (!hex(root)) return null;314+  let source = deps.sourceIdentity;315+  for (let attempt = 0; attempt < 2; attempt++) {316+    await sendV2(channel, {317+      type: "ADOPT_PREPARE",318+      device_id: source.deviceId,319+      device_name: source.deviceName,320+      device_type: source.deviceType,321+      long_term_public_key_hex: source.longTermPublicKeyHex,322+      signing_public_key_hex: source.signingPublicKeyHex ?? "",323+      vault_id: await deps.vaultId(),324+      relay_url: deps.pairState?.relayUrl,325+      ws_port: source.wsPort,326+      license,327+      snapshot_capable: deps.snapshotCapable(),328+    });329+    const reply = await receiveFrame(channel);330+    if (isAdoptRejectV2(reply) && reply.reason === "ADOPT_DEVICE_ID_COLLISION" && attempt === 0) {331+      source = await deps.rotateSourceIdentity();332+      continue;333+    }334+    if ((reply as Partial<AdoptReady> | null)?.type !== "ADOPT_READY") return null;335+    break;336+  }337+  await sendV2(channel, {338+    type: "ADOPT_GRANT",339+    root_key_hex: root,340+    vault_id: await deps.vaultId(),341+    license,342+  });343+  if ((await receiveFrame(channel) as Partial<AdoptApplied> | null)?.type !== "ADOPT_APPLIED") {344+    return null;345+  }346+  await deps.upsertPairedDevice(receiver);347+  await deps.completePendingIdentity(receiver);348+  await sendV2(channel, { type: "ADOPT_COMPLETE" });349+  return receiver;350+}

view file