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
↓ peach-browser-517c682f.tar.gzatlas at this save
files changed6 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 −3manifest.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",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",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 {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);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 });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);