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
↓ peach-browser-1ef99bf9.tar.gzatlas at this save
files changed13 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 −1manifest.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": {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",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",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 }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 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 });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> {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" &&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 mustsrc/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 onsrc/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+});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 {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 {