517c682f9968

feat: Echo `snapshot_capable` on ADOPT_GRANT so receivers skip the inline wait when no snapshot is coming (v4.6.1)

time
2026-07-30 14:38 UTC · 2 months ago
machine
koh2
parent
7410c0da42c1
6 files manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+44 −0src/background/relay-adoption-source-bg.ts+7 −5src/shared/vault-adopt.test.ts+190 −1src/shared/vault-adopt.ts+19 −3
manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.6.0",5+  "version": "4.6.1",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.6.0",3+  "version": "4.6.1",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.test.ts+44 −0
@@ -712,6 +712,50 @@712712       payload: expect.objectContaining({ status: "error", error: "identity_failed" }),713713     }));714714   });715+716+  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {717+    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).718+    // The receiver should skip the 30-second inline snapshot wait entirely719+    // and fall through to the sync pipeline — no receiveVaultSnapshot call.720+    const rootKeyHex = "f0".repeat(32);721+    const vaultId = "b".repeat(64);722+    mocks.createVault.mockResolvedValue({ vaultId });723+    mocks.openSocket.mockResolvedValue(socketWithFrames([724+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725+      {726+        type: "ADOPT_GRANT",727+        root_key_hex: rootKeyHex,728+        vault_id: vaultId,729+        license: "PPK-test",730+        source: {731+          device_id: "old-phone-id",732+          device_name: "Old phone",733+          device_type: "android",734+          long_term_public_key_hex: "99".repeat(32),735+        },736+        // NO snapshot_capable: pre-C source does not echo the field737+      },738+      { type: "ADOPT_COMPLETE" },739+    ], { closeOnEmpty: true }));740+741+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");742+    await startRelayAdoptionReceiver("receiver-password");743+    await waitForPush("ADOPTION_SOURCE_RESULT");744+745+    // Sync fallback ran immediately (no 30s wait).746+    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });747+    // Adoption completed successfully.748+    expect(mocks.pushes).toContainEqual(expect.objectContaining({749+      type: "ADOPTION_SOURCE_RESULT",750+      payload: expect.objectContaining({ status: "complete" }),751+    }));752+    // The initial sync pending marker was set (not cleared — no snapshot applied).753+    expect(mocks.storageSet).toHaveBeenCalledWith(754+      "peach_initial_sync_pending_device_id",755+      "old-phone-id",756+    );757+  });758+715759 });716760 717761 function decodeInvitation(encoded: string): { vault?: string } | null {

view file

src/background/relay-adoption-source-bg.ts+7 −5
@@ -378,7 +378,7 @@378378 379379     const deviceName = await getDeviceName();380380 381-    const device = await requestAdoption(channel, {381+    const adoptionResult = await requestAdoption(channel, {382382       deviceName,383383       generateDeviceId: () => crypto.randomUUID(),384384       // Auto-accept the SAS: both sides derive it from the same Noise@@ -436,7 +436,7 @@436436       },437437     });438438 439-    if (!device) {439+    if (!adoptionResult) {440440       if (createdVault) {441441         await rollbackAdoptedVault();442442         createdVault = false;@@ -447,12 +447,14 @@447447       });448448       return;449449     }450+    const { device, snapshotCapable } = adoptionResult;450451     // The pairing channel is still open and authenticated — try to pull the451452     // vault snapshot inline so the popup transitions immediately instead of452-    // waiting on a second sync round. Any failure (older source, timeout,453-    // corrupt chunk) falls through to the existing sync pipeline below.453+    // waiting on a second sync round. Skip the wait entirely when the source454+    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);455+    // fall through to the existing sync pipeline below.454456     let snapshotApplied = false;455-    try {457+    if (snapshotCapable) try {456458       const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);457459       const adapter = createSyncVaultAdapter();458460       await adapter.applyEntries(snapshot.entries);

view file

src/shared/vault-adopt.test.ts+190 −1
@@ -2,6 +2,7 @@22 import { computeVaultId } from "./sync-identity";33 import {44   grantAdoption,5+  isAdoptGrant,56   requestAdoption,67   sendFrame,78   type AdoptRequest,@@ -172,7 +173,7 @@172173     });173174 174175     await expect(Promise.all([receiver, source])).resolves.toEqual([175-      expect.objectContaining({ deviceId: "free-source-id" }),176+      { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },176177       expect.objectContaining({ deviceId: "receiver-id" }),177178     ]);178179     expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");@@ -441,5 +442,193 @@441442 442443     await receiver;443444     expect(createVaultFromRootKey).not.toHaveBeenCalled();445+  });446+});447+448+describe("snapshot_capable echo on ADOPT_GRANT", () => {449+  function validGrant() {450+    return {451+      type: "ADOPT_GRANT" as const,452+      root_key_hex: "11".repeat(32),453+      vault_id: "22".repeat(32),454+      license: "PPK-test",455+      source: {456+        device_id: "src-id",457+        device_name: "Source",458+        device_type: "extension" as const,459+        long_term_public_key_hex: "ab".repeat(32),460+      },461+    };462+  }463+464+  it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {465+    expect(isAdoptGrant(validGrant())).toBe(true);466+  });467+468+  it("isAdoptGrant accepts snapshot_capable: true", () => {469+    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);470+  });471+472+  it("isAdoptGrant accepts snapshot_capable: false", () => {473+    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);474+  });475+476+  it("isAdoptGrant rejects snapshot_capable with wrong type", () => {477+    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);478+  });479+480+  it("grantAdoption echoes snapshot_capable: true when request had it", async () => {481+    const [receiverChannel, sourceChannel] = memoryPair();482+    const rootKeyHex = "33".repeat(32);483+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));484+    const sourceDeps: AdoptionSourceDeps = {485+      sourceIdentity: identity("source"),486+      rootKeyHex: vi.fn(async () => rootKeyHex),487+      vaultId: vi.fn(async () => vaultId),488+      license: vi.fn(async () => "PPK-test"),489+      onConsent: vi.fn(async () => true),490+      upsertPairedDevice: vi.fn(),491+    };492+493+    // Run grantAdoption concurrently with the test driver that feeds protocol frames.494+    const grantPromise = grantAdoption(sourceChannel, {495+      type: "ADOPT_REQUEST",496+      device_id: "recv-id",497+      device_name: "Receiver",498+      device_type: "extension",499+      snapshot_capable: true,500+    }, sourceDeps);501+502+    // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY503+    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });504+    const grantBytes = await receiverChannel.receive();505+    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));506+    expect(grantFrame.type).toBe("ADOPT_GRANT");507+    expect(grantFrame.snapshot_capable).toBe(true);508+509+    // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED.510+    await sendFrame(receiverChannel, {511+      type: "ADOPT_IDENTITY",512+      device_id: "recv-id",513+      device_name: "Receiver",514+      device_type: "extension",515+      long_term_public_key_hex: "ab".repeat(32),516+    });517+    await receiverChannel.receive(); // ADOPT_COMPLETE518+    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });519+    await grantPromise;520+  });521+522+  it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {523+    const [receiverChannel, sourceChannel] = memoryPair();524+    const rootKeyHex = "44".repeat(32);525+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));526+    const sourceDeps: AdoptionSourceDeps = {527+      sourceIdentity: identity("source"),528+      rootKeyHex: vi.fn(async () => rootKeyHex),529+      vaultId: vi.fn(async () => vaultId),530+      license: vi.fn(async () => "PPK-test"),531+      onConsent: vi.fn(async () => true),532+      upsertPairedDevice: vi.fn(),533+    };534+535+    const grantPromise = grantAdoption(sourceChannel, {536+      type: "ADOPT_REQUEST",537+      device_id: "recv-id",538+      device_name: "Receiver",539+      device_type: "extension",540+      // NO snapshot_capable541+    }, sourceDeps);542+543+    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });544+    const grantBytes = await receiverChannel.receive();545+    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));546+    expect(grantFrame.type).toBe("ADOPT_GRANT");547+    expect(grantFrame.snapshot_capable).toBe(false);548+549+    await sendFrame(receiverChannel, {550+      type: "ADOPT_IDENTITY",551+      device_id: "recv-id",552+      device_name: "Receiver",553+      device_type: "extension",554+      long_term_public_key_hex: "ab".repeat(32),555+    });556+    await receiverChannel.receive(); // ADOPT_COMPLETE557+    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });558+    await grantPromise;559+  });560+561+  it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {562+    const [receiverChannel, sourceChannel] = memoryPair();563+    const rootKeyHex = "55".repeat(32);564+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));565+    const deps: AdoptionReceiverDeps = {566+      deviceName: "recv",567+      generateDeviceId: () => "recv-id",568+      async confirmSas() { return true; },569+      async createVaultFromRootKey() { return { vaultId }; },570+      async deriveLocalIdentity() {571+        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };572+      },573+      async upsertPairedDevice() {},574+      onError() {},575+    };576+577+    const receiverPromise = requestAdoption(receiverChannel, deps);578+    // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept)579+    // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED580+    await sourceChannel.receive(); // ADOPT_REQUEST581+    await sourceChannel.receive(); // ADOPT_CONFIRM582+    await sendFrame(sourceChannel, {583+      type: "ADOPT_GRANT",584+      root_key_hex: rootKeyHex,585+      vault_id: vaultId,586+      license: "PPK-test",587+      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },588+      snapshot_capable: true,589+    });590+    await sourceChannel.receive(); // ADOPT_IDENTITY591+    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });592+    await sourceChannel.receive(); // ADOPT_APPLIED593+594+    const result = await receiverPromise;595+    expect(result).not.toBeNull();596+    expect(result!.snapshotCapable).toBe(true);597+  });598+599+  it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {600+    const [receiverChannel, sourceChannel] = memoryPair();601+    const rootKeyHex = "66".repeat(32);602+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));603+    const deps: AdoptionReceiverDeps = {604+      deviceName: "recv",605+      generateDeviceId: () => "recv-id",606+      async confirmSas() { return true; },607+      async createVaultFromRootKey() { return { vaultId }; },608+      async deriveLocalIdentity() {609+        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };610+      },611+      async upsertPairedDevice() {},612+      onError() {},613+    };614+615+    const receiverPromise = requestAdoption(receiverChannel, deps);616+    await sourceChannel.receive(); // ADOPT_REQUEST617+    await sourceChannel.receive(); // ADOPT_CONFIRM618+    await sendFrame(sourceChannel, {619+      type: "ADOPT_GRANT",620+      root_key_hex: rootKeyHex,621+      vault_id: vaultId,622+      license: "PPK-test",623+      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },624+      // NO snapshot_capable (pre-C source)625+    });626+    await sourceChannel.receive(); // ADOPT_IDENTITY627+    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });628+    await sourceChannel.receive(); // ADOPT_APPLIED629+630+    const result = await receiverPromise;631+    expect(result).not.toBeNull();632+    expect(result!.snapshotCapable).toBe(false);444633   });445634 });

view file

src/shared/vault-adopt.ts+19 −3
@@ -31,6 +31,12 @@3131     ws_port?: number;3232     relay_url?: string;3333   };34+  /**35+   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so36+   * the receiver can skip the inline-snapshot wait when the source will not37+   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.38+   */39+  snapshot_capable?: boolean;3440 }3541 3642 export interface AdoptConfirm {@@ -194,7 +200,9 @@194200     /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&195201     (value.source.relay_url === undefined ||196202       (typeof value.source.relay_url === "string" &&197-        isSecureRelayUrl(value.source.relay_url)));203+        isSecureRelayUrl(value.source.relay_url))) &&204+    (value.snapshot_capable === undefined ||205+      typeof value.snapshot_capable === "boolean");198206 }199207 200208 export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {@@ -380,10 +388,16 @@380388   return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);381389 }382390 391+export interface AdoptionRequestResult {392+  device: PairedDevice;393+  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */394+  snapshotCapable: boolean;395+}396+383397 export async function requestAdoption(384398   channel: PairingTransport,385399   deps: AdoptionReceiverDeps,386-): Promise<PairedDevice | null> {400+): Promise<AdoptionRequestResult | null> {387401   const receiverLicense = await deps.license?.();388402   await sendFrame(channel, {389403     type: "ADOPT_REQUEST",@@ -413,6 +427,7 @@413427     deps.onError("ADOPT_UNEXPECTED_FRAME");414428     return null;415429   }430+  const grantSnapshotCapable = frame.snapshot_capable === true;416431 417432   const rootKeyBytes = hexToBytes(frame.root_key_hex);418433   try {@@ -470,7 +485,7 @@470485   };471486   await deps.upsertPairedDevice(sourceDevice);472487   await sendFrame(channel, { type: "ADOPT_APPLIED" });473-  return sourceDevice;488+  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };474489 }475490 476491 export async function grantAdoption(@@ -519,6 +534,7 @@519534       ws_port: deps.sourceIdentity.wsPort,520535       relay_url: deps.pairState?.relayUrl,521536     },537+    snapshot_capable: request.snapshot_capable === true,522538   });523539 524540   const identity = await receiveFrame(channel);

view file