b56583e1a012

test: cover relay adoption receiver mesh manifest path (v4.6.6)

time
2026-07-31 15:58 UTC · 2 months ago
machine
koh2
parent
84225691c252
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-bg.test.ts+146 −9
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.6.5",4+  "version": "4.6.6",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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.5",5+  "version": "4.6.6",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.5",3+  "version": "4.6.6",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-bg.test.ts+146 −9
@@ -14,8 +14,9 @@1414   getLicenseKey: vi.fn(),1515   localIdentity: vi.fn(),1616   rollbackVault: vi.fn(),17-  storageSet: vi.fn(),1817   createSyncAdapter: vi.fn(),18+  __applyIncomingDeviceManifest: vi.fn(),19+  storageSet: vi.fn(),1920   sasDecisions: [] as Array<{ id: string; ok: boolean }>,2021 }));2122 @@ -38,6 +39,139 @@3839     ...actual,3940     generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",4041   };42+  it("applies the source's device manifest after the snapshot, then acks", async () => {43+    // Mesh propagation: a 3rd browser that completes an adoption with a44+    // snapshot-capable source must also receive the source's signed device45+    // manifest and forward it to the sync adapter before the receiver46+    // resolves. The relay stays an opaque forwarder, so the whole path47+    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.48+    const rootKeyHex = "f0".repeat(32);49+    const vaultId = "b".repeat(64);50+    const longTermPubkeyHex = "99".repeat(32);51+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");52+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(53+      "../shared/sync-identity"54+    );55+    const issuerKp = await deriveSyncSigningKeypair(56+      new Uint8Array(32).fill(0x55),57+      "phone-source-id",58+    );59+    const manifest = await buildSignedDeviceManifest(60+      "phone-source-id",61+      [62+        {63+          deviceId: "peer-b",64+          deviceName: "Pool B",65+          deviceType: "extension",66+          longTermPublicKeyHex: "11".repeat(32),67+          signingPublicKeyHex: "22".repeat(32),68+          wsPort: 49152,69+          relayUrl: "wss://relay.peachpasswords.com",70+          pairedAt: 1,71+        },72+      ],73+      issuerKp.privateKey,74+    );75+    const socket = socketWithFrames([76+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },77+      {78+        type: "ADOPT_GRANT",79+        root_key_hex: rootKeyHex,80+        vault_id: vaultId,81+        license: "PPK-test",82+        snapshot_capable: true,83+        source: {84+          device_id: "phone-source-id",85+          device_name: "June's phone",86+          device_type: "android",87+          long_term_public_key_hex: longTermPubkeyHex,88+          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),89+          relay_url: "wss://relay.peachpasswords.com",90+        },91+      },92+      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is93+      // an optional follow-up frame on the same channel.94+      { type: "ADOPT_COMPLETE" },95+      { type: "ADOPT_DEVICE_MANIFEST", manifest },96+    ], { closeOnEmpty: true });97+    mocks.createVault.mockResolvedValue({ vaultId });98+    mocks.openSocket.mockResolvedValue(socket);99+    mocks.__applyIncomingDeviceManifest.mockClear();100+101+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");102+    await startRelayAdoptionReceiver("receiver-password");103+    await waitForPush("ADOPTION_SOURCE_RESULT");104+105+    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);106+    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];107+    expect(forwarded).toEqual(manifest);108+    expect(pubkey).toBeInstanceOf(Uint8Array);109+    expect(pubkey.length).toBe(32);110+    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the111+    // adapter path was skipped (the ack only follows a real apply).112+    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })113+      .mock.calls114+      .map(([bytes]) => {115+        try {116+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;117+        } catch {118+          return undefined;119+        }120+      });121+    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");122+    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.123+    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");124+  });125+126+  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {127+    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST128+    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,129+    // and quietly time out the optional 5s wait without acking a manifest130+    // or invoking the adapter path.131+    const rootKeyHex = "f0".repeat(32);132+    const vaultId = "b".repeat(64);133+    const longTermPubkeyHex = "99".repeat(32);134+    const socket = socketWithFrames([135+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },136+      {137+        type: "ADOPT_GRANT",138+        root_key_hex: rootKeyHex,139+        vault_id: vaultId,140+        license: "PPK-test",141+        snapshot_capable: true,142+        source: {143+          device_id: "phone-source-id",144+          device_name: "June's phone",145+          device_type: "android",146+          long_term_public_key_hex: longTermPubkeyHex,147+          signing_public_key_hex: "aa".repeat(32),148+          relay_url: "wss://relay.peachpasswords.com",149+        },150+      },151+      { type: "ADOPT_COMPLETE" },152+      // No ADOPT_DEVICE_MANIFEST follow-up.153+    ], { closeOnEmpty: true });154+    mocks.createVault.mockResolvedValue({ vaultId });155+    mocks.openSocket.mockResolvedValue(socket);156+    mocks.__applyIncomingDeviceManifest.mockClear();157+158+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");159+    await startRelayAdoptionReceiver("receiver-password");160+    await waitForPush("ADOPTION_SOURCE_RESULT");161+162+    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();163+    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })164+      .mock.calls165+      .map(([bytes]) => {166+        try {167+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;168+        } catch {169+          return undefined;170+        }171+      });172+    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");173+    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");174+  });41175 });42176 43177 @@ -163,14 +297,17 @@163297   });164298   mocks.getRootKeyHex.mockReturnValue("c".repeat(64));165299   mocks.upsert.mockResolvedValue(undefined);166-  mocks.createSyncAdapter.mockReturnValue({167-    buildEntryManifest: vi.fn(async () => ({})),168-    getEncryptedEntries: vi.fn(async () => ({})),169-    buildTombstoneManifest: vi.fn(async () => ({})),170-    applyEntries: vi.fn(async () => undefined),171-    applyTombstones: vi.fn(async () => undefined),172-    buildOutgoingDeviceManifest: vi.fn(async () => null),173-  });300+    const applyIncomingDeviceManifest = vi.fn(async () => undefined);301+    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;302+    mocks.createSyncAdapter.mockReturnValue({303+      buildEntryManifest: vi.fn(async () => ({})),304+      getEncryptedEntries: vi.fn(async () => ({})),305+      buildTombstoneManifest: vi.fn(async () => ({})),306+      applyEntries: vi.fn(async () => undefined),307+      applyTombstones: vi.fn(async () => undefined),308+      buildOutgoingDeviceManifest: vi.fn(async () => null),309+      applyIncomingDeviceManifest,310+    });174311   Object.defineProperty(globalThis, "chrome", {175312     configurable: true,176313     value: {

view file