// 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 { PairedDevice } from "../shared/sync-types"; const REGISTRY_KEY = "peach_paired_devices_v3"; 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 === 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"); const devices = await listPairedDevices(); const idx = devices.findIndex((d) => d.deviceId === device.deviceId); if (idx >= 0) { devices[idx] = { ...devices[idx], ...device }; } else { devices.push(device); } await chrome.storage.local.set({ [REGISTRY_KEY]: devices }); } /** * Removes the device from the LOCAL paired-devices registry only. * * 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); } } 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 }); } export interface SyncResult { 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).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.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; }