// 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. // // `lastKnownIp` is network-specific and also lives in `chrome.storage.local`, // keyed by `longTermPublicKeyHex` (not on the device record itself) so // network-specific addresses don't roam across profiles. // // `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_IP_KEY_PREFIX = "peach_last_known_ip_v3:"; const LAST_SYNC_AT_KEY = "peach_last_sync_at"; const LAST_SYNC_RESULT_KEY = "peach_last_sync_result"; export async function listPairedDevices(): 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 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; } /** * 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. const stored = { ...device, deviceName: capDeviceName(device.deviceName) }; const devices = await listPairedDevices(); const idx = devices.findIndex((d) => d.deviceId === stored.deviceId); if (idx >= 0) { devices[idx] = { ...devices[idx], ...stored }; } else { devices.push(stored); } await chrome.storage.local.set({ [REGISTRY_KEY]: 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"); const devices = await listPairedDevices(); 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 { const devices = await listPairedDevices(); 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 { const devices = await listPairedDevices(); const filtered = devices.filter((d) => d.deviceId !== deviceId); // Unpairing is local-only — there is no remote unpair RPC. The remote // device retains this device in its own registry. Noise KK rejection on // the next connection attempt is the security boundary. await chrome.storage.local.set({ [REGISTRY_KEY]: filtered }); // Also clear the per-network IP cache for this device. const device = devices.find((d) => d.deviceId === deviceId); if (device?.longTermPublicKeyHex) { await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex); } // 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. // --------------------------------------------------------------------------- export async function listDeviceRevocations(): 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 []; } } /** * 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); return (await listDeviceRevocations()).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 { const device = await findPairedDeviceById(deviceId); const existing = await listDeviceRevocations(); 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 removePairedDeviceById(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 storeRevocations([...existing, record]); await removePairedDeviceById(deviceId); return record; } /** * 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 { const existing = await listDeviceRevocations(); 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 storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS)); return fresh.map((r) => r.deviceId); } /** * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed * re-pair: a physically present user on both devices is a stronger consent * signal than whatever created the original pairing. Never call this from a * path a remote peer can reach. */ export async function clearDeviceRevocation(deviceId: string): Promise { const existing = await listDeviceRevocations(); const filtered = existing.filter((r) => r.deviceId !== deviceId); if (filtered.length === existing.length) return; await storeRevocations(filtered); } /** * 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; }> { const devices = await listPairedDevices(); const revocations = await listDeviceRevocations(); 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 storeRevocations(records: DeviceRevocation[]): Promise { await chrome.storage.local.set({ [REVOCATIONS_KEY]: records }); } 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 chrome.storage.local.remove(REGISTRY_KEY); } export async function markLastSyncedAt(deviceId: string, when: number): Promise { const devices = await listPairedDevices(); 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 getLastKnownIp(pubkeyHex: string): Promise { try { const key = LAST_IP_KEY_PREFIX + pubkeyHex; const result = await chrome.storage.local.get(key); const value = (result as Record)[key]; return typeof value === "string" ? value : null; } catch { return null; } } export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise { await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip }); } export async function clearLastKnownIp(pubkeyHex: string): Promise { await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex); } 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; }