1234567891011121314151617181920212223import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";24import { constantTimeEqualHex } from "../shared/constant-time";25import { clampSyncTimestamp } from "../shared/sync-limits";26import type { SyncOutcome } from "../shared/sync-outcome";2728const REGISTRY_KEY = "peach_paired_devices_v3";29const REVOCATIONS_KEY = "peach_device_revocations_v1";3031const MAX_REVOCATIONS = 500;32const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";33const LAST_SYNC_AT_KEY = "peach_last_sync_at";34const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3536export async function listPairedDevices(): Promise<PairedDevice[]> {37 try {38 const result = await chrome.storage.local.get(REGISTRY_KEY);39 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];40 if (Array.isArray(devices)) return devices.filter(isPairedDevice);41 return [];42 } catch {43 return [];44 }45}4647export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {48 const devices = await listPairedDevices();49 return devices.find((d) => d.deviceId === deviceId) ?? null;50}5152export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {53 const devices = await listPairedDevices();54 return devices.find((d) =>55 d.longTermPublicKeyHex !== undefined &&56 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)57 ) ?? null;58}59606162636465export async function upsertPairedDevice(device: PairedDevice): Promise<void> {66 if (!device.deviceId) throw new Error("device.deviceId is required");67 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");68 if (!device.deviceName) throw new Error("device.deviceName is required");69 if (!device.deviceType) throw new Error("device.deviceType is required");7071 const devices = await listPairedDevices();72 const idx = devices.findIndex((d) => d.deviceId === device.deviceId);73 if (idx >= 0) {74 devices[idx] = { ...devices[idx], ...device };75 } else {76 devices.push(device);77 }78 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });79}8081828384858687export async function setRelayUrlForAllPairedDevices(88 relayUrl: string,89): Promise<void> {90 const devices = await listPairedDevices();91 if (devices.length === 0) return;92 await chrome.storage.local.set({93 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),94 });95}96979899100101102103104105106107108109110111export async function removePairedDeviceById(deviceId: string): Promise<void> {112 const devices = await listPairedDevices();113 const filtered = devices.filter((d) => d.deviceId !== deviceId);114 115 116 117 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });118 119 const device = devices.find((d) => d.deviceId === deviceId);120 if (device?.longTermPublicKeyHex) {121 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);122 }123}124125126127128129export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {130 try {131 const result = await chrome.storage.local.get(REVOCATIONS_KEY);132 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];133 if (Array.isArray(value)) return value.filter(isDeviceRevocation);134 return [];135 } catch {136 return [];137 }138}139140141142143144145146147148export async function isDeviceRevoked(149 deviceId: string,150 longTermPublicKeyHex?: string,151): Promise<boolean> {152 153 154 const key = longTermPublicKeyHex?.toLowerCase();155 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);156 return (await listDeviceRevocations()).some(157 (r) =>158 r.deviceId === deviceId ||159 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),160 );161}162163164165166167168169170export async function revokeDeviceById(171 deviceId: string,172 revokedByDeviceId: string,173 options: { now?: number } = {},174): Promise<DeviceRevocation | null> {175 const device = await findPairedDeviceById(deviceId);176 const existing = await listDeviceRevocations();177 if (existing.some((r) => r.deviceId === deviceId)) return null;178179 const record: DeviceRevocation = {180 deviceId,181 182 183 184 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),185 revokedAt: options.now ?? Date.now(),186 revokedByDeviceId,187 };188 await storeRevocations([...existing, record]);189 await removePairedDeviceById(deviceId);190 return record;191}192193194195196197198199200201export async function mergeDeviceRevocations(202 incoming: DeviceRevocation[],203): Promise<string[]> {204 const existing = await listDeviceRevocations();205 const known = new Set(existing.map((r) => r.deviceId));206 const fresh = incoming207 .filter((r) => !known.has(r.deviceId))208 209 210 211 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));212 if (fresh.length === 0) return [];213 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));214 return fresh.map((r) => r.deviceId);215}216217218219220221222223export async function clearDeviceRevocation(deviceId: string): Promise<void> {224 const existing = await listDeviceRevocations();225 const filtered = existing.filter((r) => r.deviceId !== deviceId);226 if (filtered.length === existing.length) return;227 await storeRevocations(filtered);228}229230async function storeRevocations(records: DeviceRevocation[]): Promise<void> {231 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });232}233234function isDeviceRevocation(x: unknown): x is DeviceRevocation {235 if (typeof x !== "object" || x === null) return false;236 const r = x as Record<string, unknown>;237 return (238 typeof r.deviceId === "string" &&239 !!r.deviceId &&240 typeof r.longTermPublicKeyHex === "string" &&241 typeof r.revokedAt === "number" &&242 typeof r.revokedByDeviceId === "string"243 );244}245246export async function clearPairedDevices(): Promise<void> {247 await chrome.storage.local.remove(REGISTRY_KEY);248}249250export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {251 const devices = await listPairedDevices();252 const idx = devices.findIndex((d) => d.deviceId === deviceId);253 if (idx < 0) return;254 devices[idx] = { ...devices[idx], lastSyncedAt: when };255 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });256}257258export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {259 try {260 const key = LAST_IP_KEY_PREFIX + pubkeyHex;261 const result = await chrome.storage.local.get(key);262 const value = (result as Record<string, unknown>)[key];263 return typeof value === "string" ? value : null;264 } catch {265 return null;266 }267}268269export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {270 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });271}272273export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {274 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);275}276277export async function getLastSyncAt(): Promise<number | null> {278 try {279 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);280 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];281 if (typeof value === "number") return value;282 if (typeof value === "string" && value !== "") {283 const n = Number.parseInt(value, 10);284 return Number.isFinite(n) ? n : null;285 }286 return null;287 } catch {288 return null;289 }290}291292export async function setLastSyncAt(when: number): Promise<void> {293 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });294}295296297298299300301302export interface SyncResult {303 outcome: SyncOutcome;304 detail?: string;305 306 at: number;307 entriesReceived: number;308 entriesSent: number;309}310311export async function getLastSyncResult(): Promise<SyncResult | null> {312 try {313 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);314 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];315 if (316 value &&317 typeof value === "object" &&318 typeof (value as Record<string, unknown>).outcome === "string" &&319 typeof (value as Record<string, unknown>).at === "number" &&320 typeof (value as Record<string, unknown>).entriesReceived === "number" &&321 typeof (value as Record<string, unknown>).entriesSent === "number"322 ) {323 return value as SyncResult;324 }325 return null;326 } catch {327 return null;328 }329}330331export async function setLastSyncResult(result: SyncResult): Promise<void> {332 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });333}334335function isPairedDevice(x: unknown): x is PairedDevice {336 if (typeof x !== "object" || x === null) return false;337 const r = x as Record<string, unknown>;338 if (typeof r.deviceId !== "string" || !r.deviceId) return false;339 if (typeof r.pairedAt !== "number") return false;340 if (341 r.rendezvousSecretHex !== undefined &&342 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))343 ) {344 return false;345 }346 if (r.relayUrl !== undefined) {347 if (typeof r.relayUrl !== "string") return false;348 try {349 if (new URL(r.relayUrl).protocol !== "wss:") return false;350 } catch {351 return false;352 }353 }354 return true;355}