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
↓ peach-browser-cf83534a.tar.gzatlas at this save
files changed10 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 −0manifest.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": {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",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",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": {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", () => {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[]> {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() {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 excludedsrc/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)) {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