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
↓ peach-browser-444d98d8.tar.gzatlas at this save
files changed9 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 −0package.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",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 }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;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 });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+}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: {} },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> {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+});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+}