// Paired-device registry — v3 sync. // // Long-term records live in `chrome.storage.local`. Keys must not leave // the device via a third-party sync service — `longTermPublicKeyHex` and // `signingPublicKeyHex` are cryptographic keys; routing them through // Google's or Mozilla's sync back-end violates the local-only trust model. // // `deviceId` is the canonical primary key (UUID v4, stable for the // lifetime of the installation). `longTermPublicKeyHex` is also unique // per device but rotates if the user reinstalls before re-pairing, so we // key on deviceId and update the key in place during re-pair. // // The registry has only ever lived in `chrome.storage.local` for released // builds, so no cross-profile evacuation path is needed — the public keys // never leave the device via a third-party sync back-end. Unpairing is // local-only; Noise KK rejection on the next connection is the security // boundary (see removePairedDeviceById). import type { DeviceRevocation, PairedDevice } from "../shared/sync-types"; import { constantTimeEqualHex } from "../shared/constant-time"; import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits"; import type { SyncOutcome } from "../shared/sync-outcome"; const REGISTRY_KEY = "peach_paired_devices_v3"; const REVOCATIONS_KEY = "peach_device_revocations_v1"; /** Bound the list so a hostile peer cannot grow storage without limit. */ const MAX_REVOCATIONS = 500; const LAST_SYNC_AT_KEY = "peach_last_sync_at"; const LAST_SYNC_RESULT_KEY = "peach_last_sync_result"; let deviceMutationTail: Promise = Promise.resolve(); function enqueueDeviceMutation(mutation: () => Promise): Promise { const result = deviceMutationTail.then(mutation, mutation); deviceMutationTail = result.then(() => undefined, () => undefined); return result; } async function listPairedDevicesRaw(): Promise { try { const result = await chrome.storage.local.get(REGISTRY_KEY); const devices = (result as Record)[REGISTRY_KEY]; if (Array.isArray(devices)) return devices.filter(isPairedDevice); return []; } catch { return []; } } export async function listPairedDevices(): Promise { await deviceMutationTail; return listPairedDevicesRaw(); } export async function findPairedDeviceById(deviceId: string): Promise { const devices = await listPairedDevices(); return devices.find((d) => d.deviceId === deviceId) ?? null; } export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise { const devices = await listPairedDevices(); return devices.find((d) => d.longTermPublicKeyHex !== undefined && constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex) ) ?? null; } /** * Durable registry write. PB-26-213: the reusable rendezvous secret must * never reach this registry — it lives in the encrypted pair-secret record * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a * missed writer fails closed to a secretless row instead of plaintext disk. */ export async function writePairedDevices(devices: PairedDevice[]): Promise { await enqueueDeviceMutation(() => writePairedDevicesRaw(devices)); } async function writePairedDevicesRaw(devices: PairedDevice[]): Promise { await chrome.storage.local.set({ [REGISTRY_KEY]: devices.map((device) => { if (!isValidSecret(device.rendezvousSecretHex)) return device; const scrubbed = { ...device }; delete scrubbed.rendezvousSecretHex; return scrubbed; }), }); } function isValidSecret(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); } /** * Add or update a device by `deviceId`. On re-pair the new pubkey * silently replaces the old one; the deviceId is stable across the * other device's reinstall. */ export async function upsertPairedDevice(device: PairedDevice): Promise { if (!device.deviceId) throw new Error("device.deviceId is required"); if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required"); if (!device.deviceName) throw new Error("device.deviceName is required"); if (!device.deviceType) throw new Error("device.deviceType is required"); // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile // peer cannot grow the registry without limit; the local name already // obeys the same bound at the Settings write path. await enqueueDeviceMutation(async () => { const stored = { ...device, deviceName: capDeviceName(device.deviceName!) }; const devices = await listPairedDevicesRaw(); const idx = devices.findIndex((d) => d.deviceId === stored.deviceId); if (idx >= 0) devices[idx] = { ...devices[idx], ...stored }; else devices.push(stored); await writePairedDevicesRaw(devices); }); // The device-list-changed sync trigger fires from the messages.ts handlers // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they // own the post-mutation contract and have the full payload available. Firing // here would race with the test setup pattern (setSyncRunner AFTER upsert). } export async function renamePairedDevice(deviceId: string, name: string): Promise { const alias = name.trim(); if (!alias) throw new Error("device name must not be empty"); if (alias.length > 64) throw new Error("device name must be 64 chars or fewer"); await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const index = devices.findIndex((device) => device.deviceId === deviceId); if (index < 0) throw new Error("device_not_found"); devices[index] = { ...devices[index], localAlias: alias }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices }); }); } /** * Move every existing pair to the user-selected mesh relay. * * Reconnect prefers the relay stored on each pair over the global fallback, so * changing only the setting would leave established devices on the old relay. */ export async function setRelayUrlForAllPairedDevices( relayUrl: string, ): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); if (devices.length === 0) return; await chrome.storage.local.set({ [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })), }); }); // A relay change is mesh state — the next alarm tick or popup open will dial // with the new relay. No fire-and-forget trigger here; the in-flight guard // would just drop it on the burst of upserts that often follows. } /** * Removes the device from the LOCAL paired-devices registry only, leaving no * record behind. Prefer `revokeDeviceById` for anything the user would call * "remove this device" — a plain removal is silently undone the next time any * peer vouches the device back through a manifest. * * SECURITY NOTE (MV-8): this does NOT prevent the removed device from * syncing with OTHER peers in the device graph, and the device RETAINS * its copy of the vault root key (syncMasterKey) — it can still decrypt * anything it already holds and anything other peers send it. For a * lost or stolen device, removal is insufficient: use "Rotate sync * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so * the old device's key material is useless. */ export async function removePairedDeviceById(deviceId: string): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId), }); }); // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE // handler — see upsertPairedDevice for why this lives there, not here. } // --------------------------------------------------------------------------- // Device revocation (Part 5 §C) — a removal that propagates and stays removed. // --------------------------------------------------------------------------- async function listDeviceRevocationsRaw(): Promise { try { const result = await chrome.storage.local.get(REVOCATIONS_KEY); const value = (result as Record)[REVOCATIONS_KEY]; if (Array.isArray(value)) return value.filter(isDeviceRevocation); return []; } catch { return []; } } export async function listDeviceRevocations(): Promise { await deviceMutationTail; return listDeviceRevocationsRaw(); } /** * True when either the id or the long-term key is under a terminal revocation. * * `deviceId` is a self-asserted string in a manifest, so keying terminality on * it alone is bypassed by re-vouching a revoked device's key under a fresh id. * The key is the thing a revoked device actually still holds, so it is the * thing terminality has to follow. */ export async function isDeviceRevoked( deviceId: string, longTermPublicKeyHex?: string, ): Promise { // The all-zero sentinel stands for "we never stored a key for this device" // (see revokeDeviceById) and must not match a real one. const key = longTermPublicKeyHex?.toLowerCase(); const keyIsRevocable = key !== undefined && key !== "0".repeat(64); await deviceMutationTail; return (await listDeviceRevocationsRaw()).some( (r) => r.deviceId === deviceId || (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key), ); } /** * Revoke a device: record it locally, then drop it from the registry. The * record rides out in every manifest this device signs from now on, and blocks * the device being vouched back in. A stale entry may survive in the * pending-vouch queue; the confirm handler re-checks revocation before * promoting anything out of it. */ export async function revokeDeviceById( deviceId: string, revokedByDeviceId: string, options: { now?: number } = {}, ): Promise { return enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const device = devices.find((candidate) => candidate.deviceId === deviceId); const existing = await listDeviceRevocationsRaw(); if (existing.some((r) => r.deviceId === deviceId)) { // A direct re-pair in older builds could restore the paired row without // clearing its terminal revocation. "Remove" then returned early here and // left the row visible forever. Idempotency applies to the revocation // record, not to removing any stale live row that overlaps it. if (device) await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId), }); return null; } const record: DeviceRevocation = { deviceId, // A device with no stored key was never usable as a sync peer; the // all-zero key keeps the record well-formed on the wire without // pretending to name a key we never saw. longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64), revokedAt: options.now ?? Date.now(), revokedByDeviceId, }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId), [REVOCATIONS_KEY]: capRevocations([...existing, record]), }); return record; }); } /** * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed * re-pair — a physically present user on both devices. Never call this from * a path a remote peer can reach. */ export async function clearDeviceRevocation(deviceId: string): Promise { await enqueueDeviceMutation(async () => { const existing = await listDeviceRevocationsRaw(); const filtered = existing.filter((r) => r.deviceId !== deviceId); if (filtered.length !== existing.length) { await storeRevocationsRaw(filtered); } }); } /** * True when the incoming identity matches an existing paired device's deviceId * OR a terminal revocation record (by deviceId or long-term public key). * A fresh pairing ceremony must not reuse terminally revoked identity material. */ export async function hasAdoptionIdentityConflict( deviceId: string, longTermPublicKeyHex: string, ): Promise { await deviceMutationTail; const devices = await listPairedDevicesRaw(); if (devices.some((d) => d.deviceId === deviceId)) return true; const key = longTermPublicKeyHex.toLowerCase(); return (await listDeviceRevocationsRaw()).some((record) => record.deviceId === deviceId || (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key) ); } /** * Merge revocations learned from a peer's manifest. Returns the deviceIds that * were newly revoked, so the caller can evict them. * * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is * terminal, so the timestamp is display metadata only and cannot be used to * win or lose a race (HOLE 2). */ export async function mergeDeviceRevocations( incoming: DeviceRevocation[], ): Promise { return enqueueDeviceMutation(async () => { const existing = await listDeviceRevocationsRaw(); const known = new Set(existing.map((r) => r.deviceId)); const fresh = incoming .filter((r) => !known.has(r.deviceId)) // Clamped for display sanity only — a year-9999 revokedAt would render as // nonsense. The protection against ordering games is that revocation is // terminal, not this clamp (HOLE 2 audit in sync-limits.ts). .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) })); if (fresh.length === 0) return []; await storeRevocationsRaw([...existing, ...fresh]); return fresh.map((r) => r.deviceId); }); } /** * Repair the invalid paired+revoked overlap produced by older direct-pair * handlers. * * A newer direct, SAS-confirmed pair is the only state allowed to lift the * older revocation. Every other overlap resolves in favour of the revocation, * including vouched devices and pairs that predate the removal. */ export async function repairPairedRevocationOverlaps(): Promise<{ revocationsCleared: number; pairedRowsRemoved: number; }> { return enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const revocations = await listDeviceRevocationsRaw(); const revocationById = new Map(revocations.map((record) => [record.deviceId, record])); const revocationIdsToClear = new Set(); const pairedIdsToRemove = new Set(); for (const device of devices) { const revocation = revocationById.get(device.deviceId); if (!revocation) continue; if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) { revocationIdsToClear.add(device.deviceId); } else { pairedIdsToRemove.add(device.deviceId); } } if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) { return { revocationsCleared: 0, pairedRowsRemoved: 0 }; } await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)), [REVOCATIONS_KEY]: revocations.filter( (record) => !revocationIdsToClear.has(record.deviceId), ), }); return { revocationsCleared: revocationIdsToClear.size, pairedRowsRemoved: pairedIdsToRemove.size, }; }); } async function storeRevocationsRaw(records: DeviceRevocation[]): Promise { await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) }); } /** * PB-26-110: bound the ledger by evicting from whichever issuer currently holds * the most records, oldest first. * * Evicting globally-oldest-first let one early flood consume the whole cap and * permanently starve every other issuer, including genuine revocations, because * declining to evict at all is not a bounded option. Largest-holder-first keeps * the bound while guaranteeing a flood can only ever displace its own records: * once an issuer holds more than anyone else, everything it adds costs it one of * its own oldest. * * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a * 500-record cap; switch to a live-count map if the cap ever grows by orders of * magnitude. */ function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] { if (records.length <= MAX_REVOCATIONS) return records; const byIssuer = new Map(); for (const record of records) { const key = record.revokedByDeviceId ?? ""; const list = byIssuer.get(key); if (list) list.push(record); else byIssuer.set(key, [record]); } const dropped = new Set(); for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) { let victim: DeviceRevocation[] | undefined; for (const list of byIssuer.values()) { const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0); if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) { victim = list; } } if (!victim) break; const oldest = victim.find((r) => !dropped.has(r)); if (!oldest) break; dropped.add(oldest); } return records.filter((r) => !dropped.has(r)); } function isDeviceRevocation(x: unknown): x is DeviceRevocation { if (typeof x !== "object" || x === null) return false; const r = x as Record; return ( typeof r.deviceId === "string" && !!r.deviceId && typeof r.longTermPublicKeyHex === "string" && typeof r.revokedAt === "number" && typeof r.revokedByDeviceId === "string" ); } export async function clearPairedDevices(): Promise { await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY)); } export async function markLastSyncedAt(deviceId: string, when: number): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const idx = devices.findIndex((d) => d.deviceId === deviceId); if (idx < 0) return; devices[idx] = { ...devices[idx], lastSyncedAt: when }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices }); }); } export async function getLastSyncAt(): Promise { try { const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY); const value = (result as Record)[LAST_SYNC_AT_KEY]; if (typeof value === "number") return value; if (typeof value === "string" && value !== "") { const n = Number.parseInt(value, 10); return Number.isFinite(n) ? n : null; } return null; } catch { return null; } } export async function setLastSyncAt(when: number): Promise { await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when }); } /** * The last sync ATTEMPT, not just the last success. A 3-second flash in the * popup is useless for debugging a real phone, so every terminating path * writes this and Settings reads it back after the fact — including across an * MV3 service-worker restart. */ export interface SyncResult { outcome: SyncOutcome; detail?: string; /** When the attempt finished, success or not. */ at: number; entriesReceived: number; entriesSent: number; } export async function getLastSyncResult(): Promise { try { const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY); const value = (result as Record)[LAST_SYNC_RESULT_KEY]; if ( value && typeof value === "object" && typeof (value as Record).outcome === "string" && typeof (value as Record).at === "number" && typeof (value as Record).entriesReceived === "number" && typeof (value as Record).entriesSent === "number" ) { return value as SyncResult; } return null; } catch { return null; } } export async function setLastSyncResult(result: SyncResult): Promise { await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result }); } function isPairedDevice(x: unknown): x is PairedDevice { if (typeof x !== "object" || x === null) return false; const r = x as Record; if (typeof r.deviceId !== "string" || !r.deviceId) return false; if (typeof r.pairedAt !== "number") return false; if ( r.localAlias !== undefined && (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64) ) { return false; } if ( r.rendezvousSecretHex !== undefined && (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex)) ) { return false; } if (r.relayUrl !== undefined) { if (typeof r.relayUrl !== "string") return false; try { if (new URL(r.relayUrl).protocol !== "wss:") return false; } catch { return false; } } return true; }