0099d0e2d527
agree one canonical revocation record across Browser and Android, issuer bound to known-key history (Browser v5.2.34)
- time
- 2026-09-10 17:40 UTC · 1 month ago
- machine
- koh2
- agent
- deepseek/deepseek-v4-flash
- session
- cross-client remediation
- parent
- 43c9a5b81271
↓ peach-browser-0099d0e2.tar.gzatlas at this save
files changed7 files
package.json+1 −1scripts/check-cross-client-sync.mjs+17 −5src/background/sync-vault-adapter.test.ts+2 −6src/background/sync-vault-adapter.ts+53 −67src/shared/device-manifest.test.ts+15 −9src/shared/device-manifest.ts+17 −11src/shared/sync-types.ts+16 −4package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.33",3+ "version": "5.2.34",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",scripts/check-cross-client-sync.mjs+17 −5
@@ -23,14 +23,27 @@2323 if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");2424 if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");2525 26-const manifestVersion = (source, declaration) => {26+const numericConstant = (source, declaration) => {2727 const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));2828 if (!match) throw new Error(`Missing ${declaration} declaration`);2929 return Number(match[1]);3030 };31-if (manifestVersion(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== manifestVersion(androidManifest, "DEVICE_MANIFEST_VERSION")) {31+if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {3232 throw new Error("Browser and Android device-manifest versions differ");3333 }34+if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {35+ throw new Error("Browser and Android revocation record versions differ");36+}37+// The canonical revocation object is signed field-by-field, so a renamed or38+// dropped field silently stops the two clients verifying each other.39+for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {40+ if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {41+ throw new Error(`A client lacks revocation field ${field}`);42+ }43+}44+if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {45+ throw new Error("A client still carries an issuer key inside the revocation record");46+}3447 const syncProtocolVersion = (source, label) => {3548 const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);3649 if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);@@ -46,9 +59,8 @@4659 androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {4760 throw new Error("A client still accepts a peer protocol version below its own");4861 }49-if (!browserSyncTypes.includes("issuerSigningPublicKeyHex") || !browserSyncTypes.includes("signatureHex") ||50- !androidSyncMessage.includes("issuerSigningKeyHex") || !androidSyncMessage.includes("signatureHex")) {51- throw new Error("A client lacks signed revocation fields");62+if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {63+ throw new Error("A client lacks the revocation signature field");5264 }5365 if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");5466 if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");src/background/sync-vault-adapter.test.ts+2 −6
@@ -568,7 +568,6 @@568568 trustSource: "direct",569569 });570570 571- const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);572571 const signedRevocation = await signDeviceRevocation(573572 {574573 deviceId: "stolen-laptop",@@ -577,7 +576,6 @@577576 revokedByDeviceId: issuerId,578577 },579578 issuerKp.privateKey,580- issuerSigningPublicKeyHex,581579 );582580 const revocation = await buildSignedDeviceManifest(583581 issuerId,@@ -1031,13 +1029,12 @@10311029 pairedAt: 2,10321030 trustSource: "direct",10331031 });1034- const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);10351032 const signedRevocation = await signDeviceRevocation({10361033 deviceId: "stolen-laptop",10371034 longTermPublicKeyHex: stolenKey,10381035 revokedAt: Date.now(),10391036 revokedByDeviceId: issuerId,1040- }, issuerKp.privateKey, issuerSigningPublicKeyHex);1037+ }, issuerKp.privateKey);10411038 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {10421039 revoked: [signedRevocation],10431040 });@@ -1115,13 +1112,12 @@11151112 trustSource: "direct",11161113 });11171114 1118- const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);11191115 const signedRevocation = await signDeviceRevocation({11201116 deviceId: "stolen-laptop",11211117 longTermPublicKeyHex: revokedKey,11221118 revokedAt: Date.now(),11231119 revokedByDeviceId: issuerId,1124- }, issuerKp.privateKey, issuerSigningPublicKeyHex);1120+ }, issuerKp.privateKey);11251121 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {11261122 revoked: [signedRevocation],11271123 });src/background/sync-vault-adapter.ts+53 −67
@@ -191,40 +191,24 @@191191 .filter((d): d is ManifestDeviceEntry => d !== null);192192 const signedRevoked = (193193 await Promise.all(revoked.map(async (record) => {194- // A terminal record may have been issued by another mesh member.195- // Preserve its signature unchanged after checking that issuer's196- // current or immutable historical signing key is trusted locally.197- if (198- record.signatureHex &&199- record.issuerSigningPublicKeyHex &&200- record.issuerSigningKeyVersion201- ) {194+ if (record.signatureHex) {195+ // A terminal record may have been issued by another mesh member,196+ // and is re-emitted only when one of its own issuer's trusted keys197+ // verifies it. The record names no key of its own, so the issuer198+ // is resolved here and nowhere else.202199 const issuer = await findPairedDeviceById(record.revokedByDeviceId);203- const trustedKeys = [204- ...(issuer?.signingKeyHistory ?? []),205- ...(issuer?.signingPublicKeyHex206- ? [{ version: issuer.signingKeyHistory?.length207- ? Math.max(...issuer.signingKeyHistory.map((key) => key.version)) + 1208- : 1, publicKeyHex: issuer.signingPublicKeyHex }]209- : []),210- ];211- for (const key of trustedKeys) {212- if (213- key.version === record.issuerSigningKeyVersion &&214- key.publicKeyHex.toLowerCase() === record.issuerSigningPublicKeyHex.toLowerCase() &&215- await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))216- ) return record;200+ for (const key of trustedSigningKeys(issuer)) {201+ if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;217202 }203+ // Unverifiable — typically signed under a superseded canonical —204+ // is dropped from the manifest rather than poisoning every205+ // receiver. The local terminal revocation itself is retained.218206 return null;219207 }220208 // Only this device may mint a new signature; never re-issue a221209 // record attributed to a different issuer as our own.222210 if (record.revokedByDeviceId !== issuerDeviceId) return null;223- return signDeviceRevocation(224- record,225- local.signingPrivateKey,226- local.identity.signingPublicKeyHex!,227- );211+ return signDeviceRevocation(record, local.signingPrivateKey);228212 }))229213 ).filter((record): record is DeviceRevocation => record !== null);230214 return buildSignedDeviceManifest(@@ -329,6 +313,44 @@329313 }330314 331315 /**316+ * Every signing key we currently trust for a peer: its immutable history plus317+ * its active key. A forwarded revocation is authorised only by one of these,318+ * resolved from `revokedByDeviceId`. The record carries no key of its own, so a319+ * peer that merely relays a manifest cannot authorise a revocation.320+ */321+function trustedSigningKeys(322+ device:323+ | {324+ signingPublicKeyHex?: string;325+ signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;326+ }327+ | null328+ | undefined,329+): Array<{ version: number; publicKeyHex: string }> {330+ const historical = (device?.signingKeyHistory ?? [])331+ .filter(332+ (key) =>333+ Number.isInteger(key.version) &&334+ key.version > 0 &&335+ /^[0-9a-f]{64}$/i.test(key.publicKeyHex),336+ )337+ .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));338+ const current = device?.signingPublicKeyHex?.toLowerCase();339+ if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical;340+ const currentVersion =341+ historical.find((key) => key.publicKeyHex === current)?.version ??342+ historical.reduce((max, key) => Math.max(max, key.version), 0) + 1;343+ const all = [...historical, { version: currentVersion, publicKeyHex: current }];344+ return all.filter(345+ (key, index) =>346+ all.findIndex(347+ (candidate) =>348+ candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex,349+ ) === index,350+ );351+}352+353+/**332354 * Apply a device manifest issued by a peer we pair with directly.333355 *334356 * The issuer used to be required to be an "extension", on the assumption that@@ -406,46 +428,10 @@406428 // Resolve that issuer independently from our paired registry and407429 // immutable signing-key history; never bind it to the forwarder's key.408430 const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);409- if (!originalIssuer?.signingPublicKeyHex) return null;410- const historicalKeys = (originalIssuer.signingKeyHistory ?? [])411- .filter((key) =>412- Number.isInteger(key.version) &&413- key.version > 0 &&414- /^[0-9a-f]{64}$/i.test(key.publicKeyHex)415- )416- .map((key) => ({417- version: key.version,418- publicKeyHex: key.publicKeyHex.toLowerCase(),419- }));420- const currentKey = originalIssuer.signingPublicKeyHex.toLowerCase();421- const currentVersion = historicalKeys.find(422- (key) => key.publicKeyHex === currentKey,423- )?.version ?? (historicalKeys.reduce(424- (max, key) => Math.max(max, key.version),425- 0,426- ) + 1);427- const trustedKeys = [428- ...historicalKeys,429- { version: currentVersion, publicKeyHex: currentKey },430- ].filter((key, index, keys) =>431- keys.findIndex((candidate) =>432- candidate.version === key.version &&433- candidate.publicKeyHex === key.publicKeyHex434- ) === index435- );436- if (437- typeof record.issuerSigningPublicKeyHex !== "string" ||438- !Number.isInteger(record.issuerSigningKeyVersion)439- ) return null;440- const key = trustedKeys.find(441- (candidate) =>442- candidate.version === record.issuerSigningKeyVersion &&443- candidate.publicKeyHex === record.issuerSigningPublicKeyHex!.toLowerCase(),444- );445- if (!key) return null;446- return (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex)))447- ? record448- : null;431+ for (const key of trustedSigningKeys(originalIssuer)) {432+ if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;433+ }434+ return null;449435 }),450436 )451437 ).filter((record): record is DeviceRevocation => record !== null);src/shared/device-manifest.test.ts+15 −9
@@ -9,6 +9,7 @@99 import {1010 DEVICE_MANIFEST_FRESHNESS_MS,1111 DEVICE_MANIFEST_VERSION,12+ DEVICE_REVOCATION_RECORD_VERSION,1213 type ManifestDeviceEntry,1314 } from "./sync-types";1415 @@ -136,24 +137,26 @@136137 });137138 });138139 139-describe("manifest v0x03 canonical form — cross-platform KAT", () => {140- // Pinned byte-for-byte against peach-android's canonicalDeviceManifestBody.141- // If either side reorders a key, renames a field, or changes number142- // formatting, this string moves and the two stop verifying each other's143- // signatures — the exact silent divergence the manifest version guards.140+describe("manifest canonical form — cross-platform KAT", () => {141+ // Pinned byte-for-byte against peach-android's142+ // `canonicalDeviceManifestBody`. If either side reorders a key, renames a143+ // field, or changes number formatting, this string moves and the two stop144+ // verifying each other's signatures — the exact silent divergence the145+ // manifest version guards. Keep this identical to the Android vector.144146 const CANONICAL =145147 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +146148 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +147149 '"relayUrl":"wss://relay.peachpasswords.com",' +148- '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +149- '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":3,' +150+ '"signingPublicKeyHex":"' + "bb".repeat(32) + '","vouchDepth":0,"wsPort":49142}],' +151+ '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":5,' +150152 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +151- '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';153+ '"recordVersion":2,"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1",' +154+ '"signatureHex":""}]}';152155 153156 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {154157 expect(155158 canonicalise({156- manifestVersion: 3,159+ manifestVersion: DEVICE_MANIFEST_VERSION,157160 issuerDeviceId: "issuer-1",158161 issuedAt: 1_700_000_000_000,159162 devices: [@@ -166,14 +169,17 @@166169 pairedAt: 1_700_000_000_001,167170 relayUrl: "wss://relay.peachpasswords.com",168171 wsPort: 49142,172+ vouchDepth: 0,169173 },170174 ],171175 revoked: [172176 {173177 deviceId: "gone-1",174178 longTermPublicKeyHex: "cc".repeat(32),179+ recordVersion: DEVICE_REVOCATION_RECORD_VERSION,175180 revokedAt: 1_700_000_000_002,176181 revokedByDeviceId: "issuer-1",182+ signatureHex: "",177183 },178184 ],179185 }),src/shared/device-manifest.ts+17 −11
@@ -27,6 +27,7 @@2727 import {2828 DEVICE_MANIFEST_FRESHNESS_MS,2929 DEVICE_MANIFEST_VERSION,30+ DEVICE_REVOCATION_RECORD_VERSION,3031 MAX_VOUCH_DEPTH,3132 type DeviceManifestBody,3233 type DeviceRevocation,@@ -83,29 +84,26 @@8384 return canonicalise({8485 deviceId: record.deviceId,8586 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),87+ recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,8688 revokedAt: record.revokedAt,8789 revokedByDeviceId: record.revokedByDeviceId,88- issuerSigningKeyVersion: record.issuerSigningKeyVersion ?? 1,89- issuerSigningPublicKeyHex: record.issuerSigningPublicKeyHex?.toLowerCase() ?? "",9090 });9191 }9292 9393 export async function signDeviceRevocation(9494 record: DeviceRevocation,9595 signingPrivateKey: CryptoKey,96- issuerSigningPublicKeyHex: string,97- issuerSigningKeyVersion = 1,9896 ): Promise<DeviceRevocation> {99- const signed = {97+ const unsigned: DeviceRevocation = {10098 ...record,101- issuerSigningKeyVersion,102- issuerSigningPublicKeyHex: issuerSigningPublicKeyHex.toLowerCase(),99+ longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),100+ recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,103101 };104102 const signature = await signEd25519(105103 signingPrivateKey,106- new TextEncoder().encode(canonicalDeviceRevocation(signed)),104+ new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),107105 );108- return { ...signed, signatureHex: bytesToHex(signature) };106+ return { ...unsigned, signatureHex: bytesToHex(signature) };109107 }110108 111109 export async function verifyDeviceRevocation(@@ -246,6 +244,15 @@246244 "revocation.revokedByDeviceId missing",247245 );248246 }247+ if (248+ r.recordVersion != null &&249+ r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION250+ ) {251+ throw new DeviceManifestError(252+ "malformed",253+ `revocation.recordVersion unsupported (${r.recordVersion})`,254+ );255+ }249256 }250257 return manifest.body;251258 }@@ -256,9 +263,8 @@256263 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),257264 revokedAt: r.revokedAt,258265 revokedByDeviceId: r.revokedByDeviceId,266+ recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,259267 };260- if (r.issuerSigningKeyVersion != null) normalised.issuerSigningKeyVersion = r.issuerSigningKeyVersion;261- if (r.issuerSigningPublicKeyHex != null) normalised.issuerSigningPublicKeyHex = r.issuerSigningPublicKeyHex.toLowerCase();262268 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();263269 return normalised;264270 }src/shared/sync-types.ts+16 −4
@@ -173,12 +173,24 @@173173 longTermPublicKeyHex: string;174174 revokedAt: number;175175 revokedByDeviceId: string;176- /** Issuer signing-key identity, retained immutably with the record. */177- issuerSigningKeyVersion?: number;178- issuerSigningPublicKeyHex?: string;179- /** Ed25519 signature over canonical record fields (excluding signature). */176+ /**177+ * Frozen record version, part of the signed canonical bytes so a peer cannot178+ * reinterpret the record under a different version.179+ */180+ recordVersion?: number;181+ /** Ed25519 signature over the canonical record fields (excluding this one). */180182 signatureHex?: string;181183 }184+185+/**186+ * Frozen revocation record version (campaign §5.5, Amendment A-2).187+ *188+ * The record deliberately carries no issuer key: a receiver resolves189+ * `revokedByDeviceId` through its own immutable known-key history, so a peer190+ * that merely relays a manifest cannot authorise a revocation by supplying a191+ * key of its own.192+ */193+export const DEVICE_REVOCATION_RECORD_VERSION = 2 as const;182194 183195 /** Body that gets canonicalised + signed. Wire format JSON-stable. */184196 export interface DeviceManifestBody {