// Paired-device registry — v3 chirp-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. // // Migration note: earlier builds stored the registry in `chrome.storage.sync`. // `listPairedDevices` transparently migrates any existing sync-stored // registry to local storage on first read. This is a one-time, local-only // unpair is the security boundary — see removePairedDeviceById for details. 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); // One-time migration from the earlier chrome.storage.sync location. // If local storage is empty but sync has data, move it over and clear // the sync copy so keys stop leaving the device. try { const syncResult = await chrome.storage.sync.get(REGISTRY_KEY); const syncDevices = (syncResult as Record)[REGISTRY_KEY]; if (Array.isArray(syncDevices) && syncDevices.length > 0) { const valid = syncDevices.filter(isPairedDevice); await chrome.storage.local.set({ [REGISTRY_KEY]: valid }); await chrome.storage.sync.remove(REGISTRY_KEY).catch(() => undefined); return valid; } } catch { /* sync unavailable in Firefox or context — skip migration */ } 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 }); } 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 XX 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; return true; }