1234567891011121314151617181920212223import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";24import { constantTimeEqualHex } from "../shared/constant-time";25import { clampSyncTimestamp } from "../shared/sync-limits";2627const REGISTRY_KEY = "peach_paired_devices_v3";28const REVOCATIONS_KEY = "peach_device_revocations_v1";2930const MAX_REVOCATIONS = 500;31const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";32const LAST_SYNC_AT_KEY = "peach_last_sync_at";33const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3435export async function listPairedDevices(): Promise<PairedDevice[]> {36 try {37 const result = await chrome.storage.local.get(REGISTRY_KEY);38 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];39 if (Array.isArray(devices)) return devices.filter(isPairedDevice);40 return [];41 } catch {42 return [];43 }44}4546export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {47 const devices = await listPairedDevices();48 return devices.find((d) => d.deviceId === deviceId) ?? null;49}5051export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {52 const devices = await listPairedDevices();53 return devices.find((d) =>54 d.longTermPublicKeyHex !== undefined &&55 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)56 ) ?? null;57}58596061626364export async function upsertPairedDevice(device: PairedDevice): Promise<void> {65 if (!device.deviceId) throw new Error("device.deviceId is required");66 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");67 if (!device.deviceName) throw new Error("device.deviceName is required");68 if (!device.deviceType) throw new Error("device.deviceType is required");6970 const devices = await listPairedDevices();71 const idx = devices.findIndex((d) => d.deviceId === device.deviceId);72 if (idx >= 0) {73 devices[idx] = { ...devices[idx], ...device };74 } else {75 devices.push(device);76 }77 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });78}7980818283848586export async function setRelayUrlForAllPairedDevices(87 relayUrl: string,88): Promise<void> {89 const devices = await listPairedDevices();90 if (devices.length === 0) return;91 await chrome.storage.local.set({92 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),93 });94}9596979899100101102103104105106107108109110export async function removePairedDeviceById(deviceId: string): Promise<void> {111 const devices = await listPairedDevices();112 const filtered = devices.filter((d) => d.deviceId !== deviceId);113 114 115 116 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });117 118 const device = devices.find((d) => d.deviceId === deviceId);119 if (device?.longTermPublicKeyHex) {120 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);121 }122}123124125126127128export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {129 try {130 const result = await chrome.storage.local.get(REVOCATIONS_KEY);131 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];132 if (Array.isArray(value)) return value.filter(isDeviceRevocation);133 return [];134 } catch {135 return [];136 }137}138139140141142143144145146147export async function isDeviceRevoked(148 deviceId: string,149 longTermPublicKeyHex?: string,150): Promise<boolean> {151 152 153 const key = longTermPublicKeyHex?.toLowerCase();154 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);155 return (await listDeviceRevocations()).some(156 (r) =>157 r.deviceId === deviceId ||158 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),159 );160}161162163164165166167168169export async function revokeDeviceById(170 deviceId: string,171 revokedByDeviceId: string,172 options: { now?: number } = {},173): Promise<DeviceRevocation | null> {174 const device = await findPairedDeviceById(deviceId);175 const existing = await listDeviceRevocations();176 if (existing.some((r) => r.deviceId === deviceId)) return null;177178 const record: DeviceRevocation = {179 deviceId,180 181 182 183 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),184 revokedAt: options.now ?? Date.now(),185 revokedByDeviceId,186 };187 await storeRevocations([...existing, record]);188 await removePairedDeviceById(deviceId);189 return record;190}191192193194195196197198199200export async function mergeDeviceRevocations(201 incoming: DeviceRevocation[],202): Promise<string[]> {203 const existing = await listDeviceRevocations();204 const known = new Set(existing.map((r) => r.deviceId));205 const fresh = incoming206 .filter((r) => !known.has(r.deviceId))207 208 209 210 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));211 if (fresh.length === 0) return [];212 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));213 return fresh.map((r) => r.deviceId);214}215216217218219220221222export async function clearDeviceRevocation(deviceId: string): Promise<void> {223 const existing = await listDeviceRevocations();224 const filtered = existing.filter((r) => r.deviceId !== deviceId);225 if (filtered.length === existing.length) return;226 await storeRevocations(filtered);227}228229async function storeRevocations(records: DeviceRevocation[]): Promise<void> {230 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });231}232233function isDeviceRevocation(x: unknown): x is DeviceRevocation {234 if (typeof x !== "object" || x === null) return false;235 const r = x as Record<string, unknown>;236 return (237 typeof r.deviceId === "string" &&238 !!r.deviceId &&239 typeof r.longTermPublicKeyHex === "string" &&240 typeof r.revokedAt === "number" &&241 typeof r.revokedByDeviceId === "string"242 );243}244245export async function clearPairedDevices(): Promise<void> {246 await chrome.storage.local.remove(REGISTRY_KEY);247}248249export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {250 const devices = await listPairedDevices();251 const idx = devices.findIndex((d) => d.deviceId === deviceId);252 if (idx < 0) return;253 devices[idx] = { ...devices[idx], lastSyncedAt: when };254 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });255}256257export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {258 try {259 const key = LAST_IP_KEY_PREFIX + pubkeyHex;260 const result = await chrome.storage.local.get(key);261 const value = (result as Record<string, unknown>)[key];262 return typeof value === "string" ? value : null;263 } catch {264 return null;265 }266}267268export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {269 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });270}271272export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {273 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);274}275276export async function getLastSyncAt(): Promise<number | null> {277 try {278 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);279 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];280 if (typeof value === "number") return value;281 if (typeof value === "string" && value !== "") {282 const n = Number.parseInt(value, 10);283 return Number.isFinite(n) ? n : null;284 }285 return null;286 } catch {287 return null;288 }289}290291export async function setLastSyncAt(when: number): Promise<void> {292 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });293}294295export interface SyncResult {296 entriesReceived: number;297 entriesSent: number;298}299300export async function getLastSyncResult(): Promise<SyncResult | null> {301 try {302 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);303 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];304 if (305 value &&306 typeof value === "object" &&307 typeof (value as Record<string, unknown>).entriesReceived === "number" &&308 typeof (value as Record<string, unknown>).entriesSent === "number"309 ) {310 return value as SyncResult;311 }312 return null;313 } catch {314 return null;315 }316}317318export async function setLastSyncResult(result: SyncResult): Promise<void> {319 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });320}321322function isPairedDevice(x: unknown): x is PairedDevice {323 if (typeof x !== "object" || x === null) return false;324 const r = x as Record<string, unknown>;325 if (typeof r.deviceId !== "string" || !r.deviceId) return false;326 if (typeof r.pairedAt !== "number") return false;327 if (328 r.rendezvousSecretHex !== undefined &&329 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))330 ) {331 return false;332 }333 if (r.relayUrl !== undefined) {334 if (typeof r.relayUrl !== "string") return false;335 try {336 if (new URL(r.relayUrl).protocol !== "wss:") return false;337 } catch {338 return false;339 }340 }341 return true;342}