1ef99bf947b0

propagate device revocation as terminal signed records in manifest v0x02, so removing a device survives a peer vouching it back (v4.1.4)

time
2026-07-26 08:21 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
f9fa8bc4f93a
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+12 −2src/background/messages.ts+10 −3src/background/paired-devices.test.ts+65 −0src/background/paired-devices.ts+103 −2src/background/sync-alerts.ts+7 −2src/background/sync-vault-adapter.test.ts+142 −0src/background/sync-vault-adapter.ts+39 −1src/shared/device-manifest.test.ts+43 −0src/shared/device-manifest.ts+39 −1src/shared/sync-types.ts+31 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.3",4+  "version": "4.1.4",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.3",4+  "version": "4.1.4",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.1.3",3+  "version": "4.1.4",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing-bg.ts+12 −2
@@ -16,7 +16,11 @@1616   type CodePairingCallbacks,1717 } from "./code-pairing";1818 import { openPairingSocket } from "../shared/pairing-dial";19-import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";19+import {20+  clearDeviceRevocation,21+  upsertPairedDevice,22+  setLastKnownIp,23+} from "./paired-devices";2024 import { buildLocalIdentity } from "./device-identity";2125 import { computeVaultId, deriveSyncIdentityKeypair } from "../shared/sync-identity";2226 import { isUnlocked, getRootKeyHex } from "./vault";@@ -72,7 +76,13 @@7276         masterKey.fill(0);7377       }7478     },75-    persistDevice: (d) => upsertPairedDevice(d),79+    // A direct code pairing means a physically present user confirmed the SAS80+    // on both screens — the strongest consent signal in the system, and the81+    // only thing allowed to lift a terminal revocation (see revokeDeviceById).82+    persistDevice: async (d) => {83+      await clearDeviceRevocation(d.deviceId);84+      await upsertPairedDevice(d);85+    },7686     cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),7787   };7888 }

view file

src/background/messages.ts+10 −3
@@ -67,10 +67,14 @@6767 import { isNativePlatform, nativePlugin } from "../shared/native";6868 import { predictBatch as mlPredictBatch } from "./ml";6969 import type { MLFieldFeatures } from "../shared/ml";70-import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./device-identity";7170 import {71+  buildLocalIdentity,72+  getOrCreateDeviceId,73+  setDeviceName as setDeviceNameV3,74+} from "./device-identity";75+import {7276   listPairedDevices as listPairedDevicesV3,73-  removePairedDeviceById as removePairedDeviceByIdV3,77+  revokeDeviceById as revokeDeviceByIdV3,7478   upsertPairedDevice as upsertPairedDeviceV3,7579 } from "./paired-devices";7680 @@ -1417,7 +1421,10 @@14171421         if (typeof deviceId !== "string" || !deviceId) {14181422           return { ok: false, error: "invalid_payload" };14191423         }1420-        await removePairedDeviceByIdV3(deviceId);1424+        // Removing a device REVOKES it rather than just dropping the local1425+        // row: a plain removal is undone the moment any other peer vouches it1426+        // back through a manifest, which is exactly what the relay makes easy.1427+        await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());14211428         return { ok: true };14221429       }14231430 

view file

src/background/paired-devices.test.ts+65 −0
@@ -9,7 +9,12 @@99   getLastSyncAt,1010   listPairedDevices,1111   markLastSyncedAt,12+  clearDeviceRevocation,13+  isDeviceRevoked,14+  listDeviceRevocations,15+  mergeDeviceRevocations,1216   removePairedDeviceById,17+  revokeDeviceById,1318   setLastKnownIp,1419   setLastSyncAt,1520   setRelayUrlForAllPairedDevices,@@ -168,5 +173,65 @@168173     const devices = await listPairedDevices();169174     expect(devices).toHaveLength(1);170175     expect(devices[0].deviceId).toBe("real");176+  });177+178+  describe("device revocation", () => {179+    it("revokeDeviceById records the device's key and drops it from the registry", async () => {180+      await upsertPairedDevice(SAMPLE);181+      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {182+        now: 1_700_000_000_000,183+      });184+185+      expect(record).toMatchObject({186+        deviceId: SAMPLE.deviceId,187+        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,188+        revokedAt: 1_700_000_000_000,189+        revokedByDeviceId: "this-device",190+      });191+      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();192+      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);193+    });194+195+    it("revoking twice is a no-op rather than a duplicate record", async () => {196+      await upsertPairedDevice(SAMPLE);197+      await revokeDeviceById(SAMPLE.deviceId, "this-device");198+      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();199+      expect(await listDeviceRevocations()).toHaveLength(1);200+    });201+202+    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {203+      const first = await mergeDeviceRevocations([204+        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },205+      ]);206+      expect(first).toEqual(["a"]);207+208+      const second = await mergeDeviceRevocations([209+        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },210+        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },211+      ]);212+      expect(second).toEqual(["b"]);213+      expect(await listDeviceRevocations()).toHaveLength(2);214+    });215+216+    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {217+      await mergeDeviceRevocations([218+        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },219+      ]);220+      await mergeDeviceRevocations([221+        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },222+      ]);223+      const records = await listDeviceRevocations();224+      expect(records).toHaveLength(1);225+      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });226+    });227+228+    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {229+      await upsertPairedDevice(SAMPLE);230+      await revokeDeviceById(SAMPLE.deviceId, "this-device");231+      await clearDeviceRevocation(SAMPLE.deviceId);232+233+      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);234+      expect(await listDeviceRevocations()).toHaveLength(0);235+    });171236   });172237 });

view file

src/background/paired-devices.ts+103 −2
@@ -20,10 +20,13 @@2020 // local-only; Noise KK rejection on the next connection is the security2121 // boundary (see removePairedDeviceById).2222 23-import type { PairedDevice } from "../shared/sync-types";23+import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";2424 import { constantTimeEqualHex } from "../shared/constant-time";2525 2626 const REGISTRY_KEY = "peach_paired_devices_v3";27+const REVOCATIONS_KEY = "peach_device_revocations_v1";28+/** Bound the list so a hostile peer cannot grow storage without limit. */29+const MAX_REVOCATIONS = 500;2730 const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";2831 const LAST_SYNC_AT_KEY = "peach_last_sync_at";2932 const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";@@ -90,7 +93,10 @@9093 }9194 9295 /**93- * Removes the device from the LOCAL paired-devices registry only.96+ * Removes the device from the LOCAL paired-devices registry only, leaving no97+ * record behind. Prefer `revokeDeviceById` for anything the user would call98+ * "remove this device" — a plain removal is silently undone the next time any99+ * peer vouches the device back through a manifest.94100  *95101  * SECURITY NOTE (MV-8): this does NOT prevent the removed device from96102  * syncing with OTHER peers in the device graph, and the device RETAINS@@ -112,6 +118,101 @@112118   if (device?.longTermPublicKeyHex) {113119     await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);114120   }121+}122+123+// ---------------------------------------------------------------------------124+// Device revocation (Part 5 §C) — a removal that propagates and stays removed.125+// ---------------------------------------------------------------------------126+127+export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {128+  try {129+    const result = await chrome.storage.local.get(REVOCATIONS_KEY);130+    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];131+    if (Array.isArray(value)) return value.filter(isDeviceRevocation);132+    return [];133+  } catch {134+    return [];135+  }136+}137+138+export async function isDeviceRevoked(deviceId: string): Promise<boolean> {139+  return (await listDeviceRevocations()).some((r) => r.deviceId === deviceId);140+}141+142+/**143+ * Revoke a device: record it locally, then drop it from the registry and the144+ * pending-vouch queue. The record rides out in every manifest this device145+ * signs from now on, and blocks the device being vouched back in.146+ */147+export async function revokeDeviceById(148+  deviceId: string,149+  revokedByDeviceId: string,150+  options: { now?: number } = {},151+): Promise<DeviceRevocation | null> {152+  const device = await findPairedDeviceById(deviceId);153+  const existing = await listDeviceRevocations();154+  if (existing.some((r) => r.deviceId === deviceId)) return null;155+156+  const record: DeviceRevocation = {157+    deviceId,158+    // A device with no stored key was never usable as a sync peer; the159+    // all-zero key keeps the record well-formed on the wire without160+    // pretending to name a key we never saw.161+    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),162+    revokedAt: options.now ?? Date.now(),163+    revokedByDeviceId,164+  };165+  await storeRevocations([...existing, record]);166+  await removePairedDeviceById(deviceId);167+  return record;168+}169+170+/**171+ * Merge revocations learned from a peer's manifest. Returns the deviceIds that172+ * were newly revoked, so the caller can evict them.173+ *174+ * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is175+ * terminal, so the timestamp is display metadata only and cannot be used to176+ * win or lose a race (HOLE 2).177+ */178+export async function mergeDeviceRevocations(179+  incoming: DeviceRevocation[],180+): Promise<string[]> {181+  const existing = await listDeviceRevocations();182+  const known = new Set(existing.map((r) => r.deviceId));183+  const fresh = incoming.filter((r) => !known.has(r.deviceId));184+  if (fresh.length === 0) return [];185+  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));186+  return fresh.map((r) => r.deviceId);187+}188+189+/**190+ * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed191+ * re-pair: a physically present user on both devices is a stronger consent192+ * signal than whatever created the original pairing. Never call this from a193+ * path a remote peer can reach.194+ */195+export async function clearDeviceRevocation(deviceId: string): Promise<void> {196+  const existing = await listDeviceRevocations();197+  const filtered = existing.filter((r) => r.deviceId !== deviceId);198+  if (filtered.length === existing.length) return;199+  await storeRevocations(filtered);200+}201+202+async function storeRevocations(records: DeviceRevocation[]): Promise<void> {203+  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });204+}205+206+function isDeviceRevocation(x: unknown): x is DeviceRevocation {207+  if (typeof x !== "object" || x === null) return false;208+  const r = x as Record<string, unknown>;209+  return (210+    typeof r.deviceId === "string" &&211+    !!r.deviceId &&212+    typeof r.longTermPublicKeyHex === "string" &&213+    typeof r.revokedAt === "number" &&214+    typeof r.revokedByDeviceId === "string"215+  );115216 }116217 117218 export async function clearPairedDevices(): Promise<void> {

view file

src/background/sync-alerts.ts+7 −2
@@ -12,7 +12,11 @@1212 // with `consume: true`).1313 1414 export interface SyncAlert {15-  type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";15+  type:16+    | "REMOTE_PASSWORD_CHANGE"17+    | "REMOTE_DELETE"18+    | "DEVICE_KEY_ROTATED"19+    | "DEVICE_REVOKED";1620   /** VaultEntry.id — a UUID string in this codebase (spec deviation: the1721    *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).1822    *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */@@ -68,7 +72,8 @@6872   return (6973     (r.type === "REMOTE_PASSWORD_CHANGE" ||7074       r.type === "REMOTE_DELETE" ||71-      r.type === "DEVICE_KEY_ROTATED") &&75+      r.type === "DEVICE_KEY_ROTATED" ||76+      r.type === "DEVICE_REVOKED") &&7277     typeof r.entryId === "string" &&7378     typeof r.entryTitle === "string" &&7479     typeof r.deviceName === "string" &&

view file

src/background/sync-vault-adapter.test.ts+142 −0
@@ -548,6 +548,148 @@548548     });549549   });550550 551+  it("§C: a peer's revocation evicts the device and permanently blocks re-vouching", async () => {552+    const { adapter } = await setupVault();553+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");554+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");555+    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =556+      await import("./paired-devices");557+558+    const issuerId = "phone-issuer";559+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);560+    await upsertPairedDevice({561+      deviceId: issuerId,562+      deviceName: "Pixel",563+      deviceType: "android",564+      longTermPublicKeyHex: "c".repeat(64),565+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),566+      pairedAt: 1,567+      trustSource: "direct",568+    });569+    await upsertPairedDevice({570+      deviceId: "stolen-laptop",571+      deviceName: "Old Laptop",572+      deviceType: "extension",573+      longTermPublicKeyHex: "d".repeat(64),574+      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),575+      pairedAt: 2,576+      trustSource: "direct",577+    });578+579+    const revocation = await buildSignedDeviceManifest(580+      issuerId,581+      [],582+      issuerKp.privateKey,583+      {584+        revoked: [585+          {586+            deviceId: "stolen-laptop",587+            longTermPublicKeyHex: "d".repeat(64),588+            revokedAt: Date.now(),589+            revokedByDeviceId: issuerId,590+          },591+        ],592+      },593+    );594+    await adapter.applyIncomingDeviceManifest?.(595+      revocation,596+      new Uint8Array(32).fill(0xcc),597+    );598+599+    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();600+    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);601+602+    // The stolen device is now vouched back in by a later manifest carrying a603+    // fresh pairedAt — exactly the resurrection a timestamp-ordered design604+    // would allow. It must not come back.605+    const revouch = await buildSignedDeviceManifest(606+      issuerId,607+      [608+        {609+          deviceId: "stolen-laptop",610+          deviceName: "Old Laptop",611+          deviceType: "extension",612+          longTermPublicKeyHex: "d".repeat(64),613+          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),614+          pairedAt: Date.now() + 60_000,615+        },616+      ],617+      issuerKp.privateKey,618+    );619+    await adapter.applyIncomingDeviceManifest?.(620+      revouch,621+      new Uint8Array(32).fill(0xcc),622+    );623+624+    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();625+    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");626+    expect(await listPendingVouchedDevices()).toHaveLength(0);627+  });628+629+  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {630+    const { adapter } = await setupVault();631+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");632+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");633+    const {634+      isDeviceRevoked,635+      revokeDeviceById,636+      upsertPairedDevice,637+    } = await import("./paired-devices");638+    const { getOrCreateDeviceId } = await import("./device-identity");639+640+    const ourDeviceId = await getOrCreateDeviceId();641+    const issuerId = "phone-issuer";642+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);643+    await upsertPairedDevice({644+      deviceId: issuerId,645+      deviceName: "Pixel",646+      deviceType: "android",647+      longTermPublicKeyHex: "c".repeat(64),648+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),649+      pairedAt: 1,650+      trustSource: "direct",651+    });652+653+    // A compromised peer tries to sever the mesh by revoking this device.654+    const selfRevoke = await buildSignedDeviceManifest(655+      issuerId,656+      [],657+      issuerKp.privateKey,658+      {659+        revoked: [660+          {661+            deviceId: ourDeviceId,662+            longTermPublicKeyHex: "e".repeat(64),663+            revokedAt: Date.now(),664+            revokedByDeviceId: issuerId,665+          },666+        ],667+      },668+    );669+    await adapter.applyIncomingDeviceManifest?.(670+      selfRevoke,671+      new Uint8Array(32).fill(0xcc),672+    );673+    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);674+675+    // Our own revocations ride out in the manifest we build, and the revoked676+    // device is absent from the vouched list.677+    await upsertPairedDevice({678+      deviceId: "retired-tablet",679+      deviceName: "Retired Tablet",680+      deviceType: "android",681+      longTermPublicKeyHex: "f".repeat(64),682+      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),683+      pairedAt: 3,684+      trustSource: "direct",685+    });686+    await revokeDeviceById("retired-tablet", ourDeviceId);687+688+    const outgoing = await adapter.buildOutgoingDeviceManifest?.();689+    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);690+    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");691+  });692+551693   it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {552694     // The browser can never listen, so it joins the mesh THROUGH a native peer;553695     // that peer is the one holding the peer list. A phone-issued manifest must

view file

src/background/sync-vault-adapter.ts+39 −1
@@ -47,10 +47,16 @@4747 import {4848   findPairedDeviceById,4949   findPairedDeviceByPubkey,50+  listDeviceRevocations,5051   listPairedDevices,52+  mergeDeviceRevocations,53+  removePairedDeviceById,5154   upsertPairedDevice,5255 } from "./paired-devices";53-import { addPendingVouchedDevice } from "./pending-vouched-devices";56+import {57+  addPendingVouchedDevice,58+  removePendingVouchedDevice,59+} from "./pending-vouched-devices";5460 import { recordSyncAlerts } from "./sync-alerts";5561 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";5662 import type { VaultEntry } from "../shared/types";@@ -191,13 +197,17 @@191197       const masterKey = requireMasterKey();192198       const issuerDeviceId = await getOrCreateDeviceId();193199       const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);200+      const revoked = await listDeviceRevocations();201+      const revokedIds = new Set(revoked.map((r) => r.deviceId));194202       const devices = (await listPairedDevices())203+        .filter((d) => !revokedIds.has(d.deviceId))195204         .map(toManifestEntry)196205         .filter((d): d is ManifestDeviceEntry => d !== null);197206       return buildSignedDeviceManifest(198207         issuerDeviceId,199208         devices,200209         signing.privateKey,210+        { revoked },201211       );202212     },203213 @@ -281,10 +291,38 @@281291   if (body.issuerDeviceId !== issuer.deviceId) return;282292 283293   const ourDeviceId = await getOrCreateDeviceId();294+295+  // Revocations are absorbed BEFORE any vouching, so a manifest that both296+  // revokes and re-vouches the same device in one payload cannot race itself.297+  // A peer may not revoke US — that would let one compromised device sever the298+  // whole mesh — so our own id is filtered out first.299+  const newlyRevoked = await mergeDeviceRevocations(300+    body.revoked.filter((r) => r.deviceId !== ourDeviceId),301+  );302+  for (const deviceId of newlyRevoked) {303+    await removePairedDeviceById(deviceId);304+    await removePendingVouchedDevice(deviceId);305+    void recordSyncAlerts([306+      {307+        type: "DEVICE_REVOKED",308+        entryId: "",309+        entryTitle: deviceId,310+        deviceName: issuer.deviceName ?? "paired device",311+        timestamp: Date.now(),312+      },313+    ]);314+  }315+316+  const revokedIds = new Set(317+    (await listDeviceRevocations()).map((r) => r.deviceId),318+  );319+284320   for (const entry of body.devices) {285321     if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {286322       continue;287323     }324+    // Terminal: no manifest re-admits a revoked device.325+    if (revokedIds.has(entry.deviceId)) continue;288326     const existing = await findPairedDeviceById(entry.deviceId);289327 290328     // MV-7: a device we have NEVER seen does not become a sync target on

view file

src/shared/device-manifest.test.ts+43 −0
@@ -133,3 +133,46 @@133133     expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));134134   });135135 });136+137+describe("manifest v0x02 canonical form — cross-platform KAT", () => {138+  // Pinned byte-for-byte against peach-android's canonicalDeviceManifestBody.139+  // If either side reorders a key, renames a field, or changes number140+  // formatting, this string moves and the two stop verifying each other's141+  // signatures — the exact silent divergence the manifest version guards.142+  const CANONICAL =143+    '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +144+    '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +145+    '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +146+    '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":2,' +147+    '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +148+    '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';149+150+  it("canonicalises a manifest body carrying a revocation to the pinned string", () => {151+    expect(152+      canonicalise({153+        manifestVersion: 2,154+        issuerDeviceId: "issuer-1",155+        issuedAt: 1_700_000_000_000,156+        devices: [157+          {158+            deviceId: "peer-1",159+            deviceName: "Peer One",160+            deviceType: "android",161+            longTermPublicKeyHex: "aa".repeat(32),162+            signingPublicKeyHex: "bb".repeat(32),163+            pairedAt: 1_700_000_000_001,164+            wsPort: 49142,165+          },166+        ],167+        revoked: [168+          {169+            deviceId: "gone-1",170+            longTermPublicKeyHex: "cc".repeat(32),171+            revokedAt: 1_700_000_000_002,172+            revokedByDeviceId: "issuer-1",173+          },174+        ],175+      }),176+    ).toBe(CANONICAL);177+  });178+});

view file

src/shared/device-manifest.ts+39 −1
@@ -25,6 +25,7 @@2525   DEVICE_MANIFEST_FRESHNESS_MS,2626   DEVICE_MANIFEST_VERSION,2727   type DeviceManifestBody,28+  type DeviceRevocation,2829   type ManifestDeviceEntry,2930   type SignedDeviceManifest,3031 } from "./sync-types";@@ -54,7 +55,7 @@5455   issuerDeviceId: string,5556   devices: ManifestDeviceEntry[],5657   signingPrivateKey: CryptoKey,57-  options: { now?: number } = {},58+  options: { now?: number; revoked?: DeviceRevocation[] } = {},5859 ): Promise<SignedDeviceManifest> {5960   if (!issuerDeviceId) throw new Error("issuerDeviceId is required");6061   const body: DeviceManifestBody = {@@ -62,6 +63,7 @@6263     issuerDeviceId,6364     issuedAt: options.now ?? Date.now(),6465     devices: devices.map(normaliseEntry),66+    revoked: (options.revoked ?? []).map(normaliseRevocation),6567   };6668   const canonical = canonicalise(body);6769   const signature = await signEd25519(@@ -157,7 +159,43 @@157159       throw new DeviceManifestError("malformed", "device.wsPort invalid");158160     }159161   }162+163+  if (!Array.isArray(manifest.body.revoked)) {164+    throw new DeviceManifestError("malformed", "revoked is not an array");165+  }166+  for (const r of manifest.body.revoked) {167+    if (typeof r.deviceId !== "string" || !r.deviceId) {168+      throw new DeviceManifestError("malformed", "revocation.deviceId missing");169+    }170+    if (171+      typeof r.longTermPublicKeyHex !== "string" ||172+      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)173+    ) {174+      throw new DeviceManifestError(175+        "malformed",176+        "revocation.longTermPublicKeyHex invalid",177+      );178+    }179+    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {180+      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");181+    }182+    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {183+      throw new DeviceManifestError(184+        "malformed",185+        "revocation.revokedByDeviceId missing",186+      );187+    }188+  }160189   return manifest.body;190+}191+192+function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {193+  return {194+    deviceId: r.deviceId,195+    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),196+    revokedAt: r.revokedAt,197+    revokedByDeviceId: r.revokedByDeviceId,198+  };161199 }162200 163201 function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {

view file

src/shared/sync-types.ts+31 −1
@@ -125,7 +125,10 @@125125 // v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)126126 // ---------------------------------------------------------------------------127127 128-export const DEVICE_MANIFEST_VERSION = 0x01 as const;128+/** 0x02 added the `revoked` array to the signed body. Coordinated wire change129+ *  with peach-android — the canonical signing input differs, so a 0x01 peer and130+ *  a 0x02 peer cannot verify each other's manifests. */131+export const DEVICE_MANIFEST_VERSION = 0x02 as const;129132 /** Manifests older than this are silently rejected (v2 brief §4.3). */130133 export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;131134 @@ -151,12 +154,39 @@151154   pairedAt: number;152155 }153156 157+/**158+ * A device that must no longer be synced with. Revocation is TERMINAL: once a159+ * deviceId appears here, no manifest may re-vouch it. That is deliberate —160+ * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a161+ * revoked device just by issuing a manifest with a fresh `pairedAt`, and every162+ * timestamp in a manifest is peer-supplied. Re-admitting a device requires a163+ * direct, SAS-confirmed re-pair with a physically present user.164+ *165+ * HONEST LIMIT: every mesh member holds the vault root key, and signing keys166+ * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these167+ * signatures prove *vault membership*, not device identity. A revoked device168+ * still holds the root key and, running modified software, can ignore its own169+ * revocation. This mechanism stops honest peers from talking to a revoked170+ * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely171+ * compromised device needs the master password and recovery words rotated.172+ */173+export interface DeviceRevocation {174+  deviceId: string;175+  /** X25519 sync identity key at the moment of revocation, so the record names176+   *  the exact key that was cut off rather than just an id. */177+  longTermPublicKeyHex: string;178+  revokedAt: number;179+  /** deviceId that issued the revocation, for display only. */180+  revokedByDeviceId: string;181+}182+154183 /** Body that gets canonicalised + signed. Wire format JSON-stable. */155184 export interface DeviceManifestBody {156185   manifestVersion: number;157186   issuerDeviceId: string;158187   issuedAt: number;159188   devices: ManifestDeviceEntry[];189+  revoked: DeviceRevocation[];160190 }161191 162192 export interface SignedDeviceManifest {

view file