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
7 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 −4
package.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",

view file

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");

view file

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     });

view file

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);

view file

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       }),

view file

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 }

view file

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 {

view file