cf83534a92ff

fail-closed malformed ledger and document locked-spool custody divergence (v5.2.44)

time
2026-09-11 02:58 UTC · 1 month ago
machine
koh2
agent
synthetic/hf:zai-org/GLM-5.2
session
campaign finalization §18.4 §18.5
parent
19cc6123e901
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+10 −2src/background/paired-devices.test.ts+29 −1src/background/paired-devices.ts+29 −17src/background/sync-generation.ts+5 −5src/background/sync-trigger.ts+13 −2src/shared/sync-outcome.test.ts+1 −0src/shared/sync-outcome.ts+5 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.43",4+  "version": "5.2.44",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": "5.2.43",5+  "version": "5.2.44",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": "5.2.43",3+  "version": "5.2.44",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+10 −2
@@ -79,6 +79,7 @@7979   listPairedDevices as listPairedDevicesV3,8080   renamePairedDevice as renamePairedDeviceV3,8181   revokeDeviceById as revokeDeviceByIdV3,82+  TrustDataCorruptedError,8283   upsertPairedDevice as upsertPairedDeviceV3,8384 } from "./paired-devices";8485 import { dropPairSecret } from "./pair-secrets";@@ -1620,8 +1621,15 @@16201621 16211622       case "LIST_PAIRED_DEVICES": {16221623         if (vault.isDecoyMode()) return { ok: true, data: { devices: [] } };1623-        const devices = await listPairedDevicesV3();1624-        return { ok: true, data: { devices } };1624+        try {1625+          const devices = await listPairedDevicesV3();1626+          return { ok: true, data: { devices } };1627+        } catch (err) {1628+          if (err instanceof TrustDataCorruptedError) {1629+            return { ok: false, error: "trust_data_corrupted" };1630+          }1631+          throw err;1632+        }16251633       }16261634 16271635       case "RENAME_PAIRED_DEVICE": {

view file

src/background/paired-devices.test.ts+29 −1
@@ -16,6 +16,7 @@1616   revokeDeviceById,1717   setLastSyncAt,1818   setRelayUrlForAllPairedDevices,19+  TrustDataCorruptedError,1920   upsertPairedDevice,2021   validatePairedDeviceIdentities,2122 } from "./paired-devices";@@ -231,7 +232,7 @@231232     expect(await getLastSyncAt()).toBe(1700003000000);232233   });233234 234-  it("listPairedDevices ignores garbage entries silently", async () => {235+  it("listPairedDevices filters invalid entries from an otherwise valid array", async () => {235236     (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({236237       peach_paired_devices_v3: [237238         { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },@@ -243,6 +244,33 @@243244     const devices = await listPairedDevices();244245     expect(devices).toHaveLength(1);245246     expect(devices[0].deviceId).toBe("real");247+  });248+249+  // §18.4: a corrupt persisted ledger must not be read as empty — that would250+  // silently un-revoke every device. A non-array value is integrity damage.251+  it("listPairedDevices throws TrustDataCorruptedError on a non-array registry", async () => {252+    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({253+      peach_paired_devices_v3: { corrupted: true },254+    });255+    await expect(listPairedDevices()).rejects.toThrow(TrustDataCorruptedError);256+  });257+258+  it("listDeviceRevocations throws TrustDataCorruptedError on a non-array ledger", async () => {259+    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({260+      peach_device_revocations_v1: "not-an-array",261+    });262+    await expect(listDeviceRevocations()).rejects.toThrow(TrustDataCorruptedError);263+  });264+265+  it("a corrupt registry is not overwritten by a mutation attempt", async () => {266+    const corrupt = { corrupted: true };267+    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({268+      peach_paired_devices_v3: corrupt,269+    });270+    await expect(upsertPairedDevice(SAMPLE)).rejects.toThrow(TrustDataCorruptedError);271+    // The corrupt payload is preserved — the mutation never wrote.272+    const result = await chrome.storage.local.get("peach_paired_devices_v3");273+    expect(result["peach_paired_devices_v3"]).toBe(corrupt);246274   });247275 248276   describe("device revocation", () => {

view file

src/background/paired-devices.ts+29 −17
@@ -34,15 +34,29 @@3434   return result;3535 }3636 37+/**38+ * §18.4: a corrupt persisted registry is an integrity error, not an empty39+ * list. An absent key is legitimate (no devices paired yet); a present but40+ * unparseable value means trust state was damaged and must not be silently41+ * cleared — that would un-revoke every device. The corrupt payload is42+ * preserved (this function never writes) so a future repair can attempt43+ * recovery.44+ */45+export class TrustDataCorruptedError extends Error {46+  constructor(key: string, detail: string) {47+    super(`trust_data_corrupted:${key}:${detail}`);48+    this.name = "TrustDataCorruptedError";49+  }50+}51+3752 async function listPairedDevicesRaw(): Promise<PairedDevice[]> {38-  try {39-    const result = await chrome.storage.local.get(REGISTRY_KEY);40-    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];41-    if (Array.isArray(devices)) return devices.filter(isPairedDevice);42-    return [];43-  } catch {44-    return [];53+  const result = await chrome.storage.local.get(REGISTRY_KEY);54+  const devices = (result as Record<string, unknown>)[REGISTRY_KEY];55+  if (devices === undefined) return [];56+  if (!Array.isArray(devices)) {57+    throw new TrustDataCorruptedError(REGISTRY_KEY, `expected array, got ${typeof devices}`);4558   }59+  return devices.filter(isPairedDevice);4660 }4761 4862 export async function listPairedDevices(): Promise<PairedDevice[]> {@@ -59,14 +73,13 @@5973   const devices = await listPairedDevices();6074   return devices.find((d) =>6175     d.longTermPublicKeyHex !== undefined &&62-    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)76+    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex),6377   ) ?? null;6478 }6579 6680 /**6781  * Durable registry write. PB-26-213: the reusable rendezvous secret must68- * never reach this registry — it lives in the encrypted pair-secret record69- * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a82+ * never persist into the public registry — it is stripped here so a7083  * missed writer fails closed to a secretless row instead of plaintext disk.7184  */7285 export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {@@ -267,14 +280,13 @@267280 // ---------------------------------------------------------------------------268281 269282 async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {270-  try {271-    const result = await chrome.storage.local.get(REVOCATIONS_KEY);272-    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];273-    if (Array.isArray(value)) return value.filter(isDeviceRevocation);274-    return [];275-  } catch {276-    return [];283+  const result = await chrome.storage.local.get(REVOCATIONS_KEY);284+  const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];285+  if (value === undefined) return [];286+  if (!Array.isArray(value)) {287+    throw new TrustDataCorruptedError(REVOCATIONS_KEY, `expected array, got ${typeof value}`);277288   }289+  return value.filter(isDeviceRevocation);278290 }279291 280292 export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {

view file

src/background/sync-generation.ts+5 −5
@@ -16,7 +16,7 @@1616   PENDING_ADOPTION_HOLDER_KEY,1717   PENDING_ADOPTION_IDENTITY_KEY,1818 } from "./pending-adoption-holder";19-import { REGISTRY_KEY, REVOCATIONS_KEY, listPairedDevices } from "./paired-devices";19+import { REGISTRY_KEY, REVOCATIONS_KEY, listPairedDevices, TrustDataCorruptedError } from "./paired-devices";2020 2121 export const GENERATION_KEY = "peach_sync_generation_v1";2222 @@ -308,9 +308,6 @@308308   generationIdentity = identity;309309 }310310 311-function emptyList(value: unknown): boolean {312-  return !Array.isArray(value) || value.length === 0;313-}314311 315312 function emptyRecord(value: unknown): boolean {316313   return !value || typeof value !== "object" || Object.keys(value as object).length === 0;@@ -338,7 +335,10 @@338335   },339336 340337   async hasRevocations() {341-    return !emptyList((await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY]);338+    const value = (await chrome.storage.local.get(REVOCATIONS_KEY))[REVOCATIONS_KEY];339+    if (value === undefined) return false;340+    if (!Array.isArray(value)) throw new TrustDataCorruptedError(REVOCATIONS_KEY, `expected array, got ${typeof value}`);341+    return value.length > 0;342342   },343343 344344   async hasPendingHolders() {

view file

src/background/sync-trigger.ts+13 −2
@@ -20,6 +20,7 @@2020   markLastSyncedAt,2121   setLastSyncAt,2222   setLastSyncResult,23+  TrustDataCorruptedError,2324 } from "./paired-devices";2425 import type { PairedDevice } from "../shared/sync-types";2526 import {@@ -170,7 +171,16 @@170171       finish({ outcome: decision });171172       return;172173     }173-    const devices = await listPairedDevices();174+    let devices: PairedDevice[];175+    try {176+      devices = await listPairedDevices();177+    } catch (err) {178+      if (err instanceof TrustDataCorruptedError) {179+        finish({ outcome: "trust_data_corrupted", detail: err.message });180+        return;181+      }182+      throw err;183+    }174184     const routeCapable = devices.filter(175185       (d) =>176186         (@@ -291,8 +301,9 @@291301   if (error.includes("sync_disabled")) return "sync_disabled";292302   if (error.includes("private_relay_unset")) return "private_relay_unset";293303   if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";294-  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";304+  if (error.includes("trust_data_corrupted")) return "trust_data_corrupted";295305   if (error.includes("sync_timeout")) return "sync_timeout";306+  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";296307   // A transport that hung up mid-protocol. The alarm retries, and the peer may297308   // well have applied what we already sent, so this is network-class (yellow)298309   // rather than "the app is broken" (red). A close during the *dial* is excluded

view file

src/shared/sync-outcome.test.ts+1 −0
@@ -99,6 +99,7 @@9999       sync_generation_unknown: "error",100100       sync_timeout: "warn",101101       connection_lost: "warn",102+      trust_data_corrupted: "error",102103       run_failed: "error",103104     };104105     for (const [outcome, indicator] of Object.entries(expected)) {

view file

src/shared/sync-outcome.ts+5 −0
@@ -22,6 +22,7 @@2222   | "sync_generation_unknown"2323   | "sync_timeout"2424   | "connection_lost"25+  | "trust_data_corrupted"2526   | "run_failed";2627 2728 /**@@ -43,6 +44,9 @@4344   // read, has not synced and must be told so.4445   "reset_required",4546   "sync_generation_unknown",47+  // §18.4: a corrupt persisted ledger must not be read as empty — it is a48+  // trust integrity error, not a network miss or a healthy empty state.49+  "trust_data_corrupted",4650   "run_failed",4751 ]);4852 @@ -155,6 +159,7 @@155159   sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",156160   sync_timeout: "The device stopped responding mid-sync",157161   connection_lost: "The connection dropped mid-sync — will retry",162+  trust_data_corrupted: "Device trust data is damaged — re-pair your devices after updating",158163   run_failed: "Sync failed",159164 };160165 

view file