157f3326d8f1
fix: bind sync manifests to authenticated peer identity (v4.0.38)
- time
- 2026-07-26 02:13 UTC · 3 months ago
- machine
- koh2
- agent
- gpt-5
- session
- peach relay sync implementation
- parent
- f92006bbfad8
↓ peach-browser-157f3326.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+38 −2src/background/sync-coordinator.ts+17 −28src/background/sync-vault-adapter.test.ts+26 −5src/background/sync-vault-adapter.ts+12 −3src/shared/sync-identity.test.ts+5 −4src/shared/sync-identity.ts+2 −2src/shared/sync-types.ts+1 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.37",4+ "version": "4.0.38",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.37",4+ "version": "4.0.38",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.0.37",3+ "version": "4.0.38",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/sync-coordinator.test.ts+38 −2
@@ -255,7 +255,10 @@255255 const vault: SyncVaultAdapter = {256256 ...base,257257 async buildOutgoingDeviceManifest() { return outgoing; },258- async applyIncomingDeviceManifest(m) { applied.push(m); },258+ async applyIncomingDeviceManifest(m, authenticatedKey) {259+ expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);260+ applied.push(m);261+ },259262 };260263 261264 const initPromise = runSyncInitiator(session, vault);@@ -272,7 +275,7 @@272275 const ourManifest = await peer.receive();273276 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");274277 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {275- expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");278+ expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");276279 }277280 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });278281 await peer.receive(); // done@@ -284,6 +287,39 @@284287 expect(outcome.status).toBe("completed");285288 expect(applied).toHaveLength(1);286289 expect(applied[0].body.issuerDeviceId).toBe("them");290+ });291+292+ it("uses null to represent an absent device manifest", async () => {293+ const [session, peer] = inMemoryPair();294+ const applyIncoming = vi.fn();295+ const vault: SyncVaultAdapter = {296+ ...makeVaultAdapter({}),297+ async buildOutgoingDeviceManifest() { return null; },298+ applyIncomingDeviceManifest: applyIncoming,299+ };300+301+ const initPromise = runSyncInitiator(session, vault);302+ const peerProtocol = (async () => {303+ await peer.receive();304+ await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });305+ await peer.receive();306+ await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });307+ await peer.receive();308+ await peer.send({ type: "SYNC_REQUEST", entryIds: [] });309+ await peer.receive();310+ await peer.receive();311+ await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });312+ const manifest = await peer.receive();313+ expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });314+ await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });315+ await peer.receive();316+ await peer.send({ type: "SYNC_DONE" });317+ })();318+319+ const outcome = await initPromise;320+ await peerProtocol;321+ expect(outcome.status).toBe("completed");322+ expect(applyIncoming).not.toHaveBeenCalled();287323 });288324 289325 it("returns errored when peer sends a wrong frame", async () => {src/background/sync-coordinator.ts+17 −28
@@ -24,16 +24,6 @@2424 2525 const SESSION_TIMEOUT_MS = 30_000;2626 27-// Sentinel signature used when an adapter can't build a real manifest (e.g.28-// the master key isn't accessible from the coordinator context). The peer29-// recognises this exact value and skips signature verification. 64 hex30-// chars of zero is impossible to produce as a real Ed25519 signature for a31-// fresh body, so collisions are negligible.32-const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {33- body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },34- signatureHex: "0".repeat(128),35-};36-3727 /**3828 * Vault-side adapter — the coordinator's dependency on the vault layer.3929 * The vault holds the canonical entry/tombstone tables; the coordinator@@ -66,8 +56,15 @@6656 * step entirely. Both ends must opt in for the exchange to occur.6757 */6858 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;69- /** v2 brief §4.3 — handle a manifest received from the peer. */70- applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;59+ /**60+ * Handle a manifest received from the peer. The transport-authenticated61+ * Noise static key is supplied separately so a self-asserted issuer id62+ * cannot select a different paired-device verification key.63+ */64+ applyIncomingDeviceManifest?(65+ manifest: SignedDeviceManifest,66+ authenticatedRemoteStaticPublicKey: Uint8Array,67+ ): Promise<void>;7168 }7269 7370 export interface SyncOutcome {@@ -192,28 +189,20 @@192189 // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends193190 // must expose the adapter methods. Disabling on either side cleanly194191 // bypasses the step. The exchange is symmetric on the wire; the195- // production adapter still treats the extension as the trust anchor and196- // only applies extension-issued manifests.192+ // production adapter accepts only a directly paired issuer whose stored193+ // long-term key matches the Noise-authenticated remote static key.197194 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {198195 const ours = await vault.buildOutgoingDeviceManifest();199- if (ours) {200- await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });201- } else {202- // No manifest to send (e.g. master key unavailable) — still emit a203- // protocol frame so the peer's recv doesn't hang. Empty body is204- // serialised by the adapter when present; if it returned null we205- // synthesise a no-op shape the receiver can ignore.206- await sendMessage(session, {207- type: "SYNC_DEVICE_MANIFEST",208- manifest: EMPTY_MANIFEST_PLACEHOLDER,209- });210- }196+ await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });211197 const manifestIn = await receiveMessage(session, timer);212198 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {213199 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);214200 }215- if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {216- await vault.applyIncomingDeviceManifest(manifestIn.manifest);201+ if (manifestIn.manifest) {202+ await vault.applyIncomingDeviceManifest(203+ manifestIn.manifest,204+ session.remoteStaticPublicKey,205+ );217206 }218207 }219208 src/background/sync-vault-adapter.test.ts+26 −5
@@ -389,7 +389,10 @@389389 issuerKp.privateKey,390390 );391391 392- await adapter.applyIncomingDeviceManifest?.(manifest);392+ await adapter.applyIncomingDeviceManifest?.(393+ manifest,394+ new Uint8Array(32).fill(0xcc),395+ );393396 394397 // MV-7: the never-seen device is NOT auto-paired; it waits in the pending395398 // queue for explicit user confirmation.@@ -460,7 +463,10 @@460463 issuerKp.privateKey,461464 );462465 463- await adapter.applyIncomingDeviceManifest?.(manifest);466+ await adapter.applyIncomingDeviceManifest?.(467+ manifest,468+ new Uint8Array(32).fill(0xcc),469+ );464470 // recordSyncAlerts is fire-and-forget — flush microtasks.465471 for (let i = 0; i < 25; i++) await Promise.resolve();466472 @@ -506,7 +512,10 @@506512 }],507513 mobileIssuerKp.privateKey,508514 );509- await adapter.applyIncomingDeviceManifest?.(mobileManifest);515+ await adapter.applyIncomingDeviceManifest?.(516+ mobileManifest,517+ new Uint8Array(32).fill(0x11),518+ );510519 511520 expect(await findPairedDeviceById("mesh-peer")).toBeNull();512521 expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);@@ -544,7 +553,10 @@544553 }],545554 vouchedKp.privateKey,546555 );547- await adapter.applyIncomingDeviceManifest?.(onward);556+ await adapter.applyIncomingDeviceManifest?.(557+ onward,558+ new Uint8Array(32).fill(0x11),559+ );548560 expect(await findPairedDeviceById("should-not-add")).toBeNull();549561 expect(await listPendingVouchedDevices()).toEqual([]);550562 @@ -571,6 +583,12 @@571583 }],572584 extensionKp.privateKey,573585 );586+ await adapter.applyIncomingDeviceManifest?.(587+ good,588+ new Uint8Array(32).fill(0x44),589+ );590+ expect(await findPairedDeviceById("forged-add")).toBeNull();591+574592 const forged = {575593 ...good,576594 signatureHex:@@ -578,7 +596,10 @@578596 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +579597 good.signatureHex.slice(4),580598 };581- await adapter.applyIncomingDeviceManifest?.(forged);599+ await adapter.applyIncomingDeviceManifest?.(600+ forged,601+ new Uint8Array(32).fill(0x33),602+ );582603 expect(await findPairedDeviceById("forged-add")).toBeNull();583604 });584605 src/background/sync-vault-adapter.ts+12 −3
@@ -28,6 +28,7 @@2828 computeVaultId,2929 deriveSyncSigningKeypair,3030 hexToPubkey,31+ pubkeyToHex,3132 } from "../shared/sync-identity";3233 import {3334 buildSignedDeviceManifest,@@ -44,6 +45,7 @@4445 } from "./vault";4546 import {4647 findPairedDeviceById,48+ findPairedDeviceByPubkey,4749 listPairedDevices,4850 upsertPairedDevice,4951 } from "./paired-devices";@@ -198,9 +200,12 @@198200 );199201 },200202 201- async applyIncomingDeviceManifest(manifest) {203+ async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {202204 requireUnlocked();203- await applyVerifiedPeerManifest(manifest);205+ await applyVerifiedPeerManifest(206+ manifest,207+ authenticatedRemoteStaticPublicKey,208+ );204209 },205210 };206211 }@@ -245,13 +250,17 @@245250 */246251 async function applyVerifiedPeerManifest(247252 manifest: SignedDeviceManifest,253+ authenticatedRemoteStaticPublicKey: Uint8Array,248254 ): Promise<void> {249255 const issuerId = manifest?.body?.issuerDeviceId;250256 if (typeof issuerId !== "string" || !issuerId) return;251257 252- const issuer = await findPairedDeviceById(issuerId);258+ if (authenticatedRemoteStaticPublicKey.length !== 32) return;259+ const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);260+ const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);253261 if (254262 !issuer ||263+ issuer.deviceId !== issuerId ||255264 issuer.trustSource !== "direct" ||256265 !issuer.signingPublicKeyHex ||257266 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)src/shared/sync-identity.test.ts+5 −4
@@ -74,17 +74,18 @@7474 7575 describe("pubkeyToHex / hexToPubkey", () => {7676 it("round-trips", () => {77- const pk = Uint8Array.from([0x00, 0xff, 0xa5, 0x10, 0x42]);77+ const pk = Uint8Array.from(Array.from({ length: 32 }, (_, i) => i));7878 const hex = pubkeyToHex(pk);79- expect(hex).toBe("00ffa51042");8079 expect(hexToPubkey(hex)).toEqual(pk);8180 });8281 83- it("rejects odd-length hex", () => {82+ it("rejects keys that are not exactly 32 bytes", () => {8483 expect(() => hexToPubkey("abc")).toThrow();84+ expect(() => hexToPubkey("00".repeat(31))).toThrow();85+ expect(() => hexToPubkey("00".repeat(33))).toThrow();8586 });8687 8788 it("rejects invalid hex chars", () => {88- expect(() => hexToPubkey("zz")).toThrow();89+ expect(() => hexToPubkey("zz".repeat(32))).toThrow();8990 });9091 });src/shared/sync-identity.ts+2 −2
@@ -185,8 +185,8 @@185185 }186186 187187 export function hexToPubkey(hex: string): Uint8Array {188- if (hex.length % 2 !== 0) throw new Error("hex length must be even");189- const out = new Uint8Array(hex.length / 2);188+ if (hex.length !== 64) throw new Error("public key must be exactly 32 bytes");189+ const out = new Uint8Array(32);190190 for (let i = 0; i < out.length; i++) {191191 const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);192192 if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);src/shared/sync-types.ts+1 −1
@@ -98,7 +98,7 @@9898 | { type: "SYNC_REQUEST"; entryIds: string[] }9999 | { type: "SYNC_ENTRIES"; entries: Record<string, string> }100100 | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }101- | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }101+ | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }102102 | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }103103 | { type: "SYNC_ERROR"; error: string };104104