1234567891011121314151617181920212223import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";24import { constantTimeEqualHex } from "../shared/constant-time";2526const REGISTRY_KEY = "peach_paired_devices_v3";27const REVOCATIONS_KEY = "peach_device_revocations_v1";2829const MAX_REVOCATIONS = 500;30const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";31const LAST_SYNC_AT_KEY = "peach_last_sync_at";32const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3334export async function listPairedDevices(): Promise<PairedDevice[]> {35 try {36 const result = await chrome.storage.local.get(REGISTRY_KEY);37 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];38 if (Array.isArray(devices)) return devices.filter(isPairedDevice);39 return [];40 } catch {41 return [];42 }43}4445export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {46 const devices = await listPairedDevices();47 return devices.find((d) => d.deviceId === deviceId) ?? null;48}4950export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {51 const devices = await listPairedDevices();52 return devices.find((d) =>53 d.longTermPublicKeyHex !== undefined &&54 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)55 ) ?? null;56}57585960616263export async function upsertPairedDevice(device: PairedDevice): Promise<void> {64 if (!device.deviceId) throw new Error("device.deviceId is required");65 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");66 if (!device.deviceName) throw new Error("device.deviceName is required");67 if (!device.deviceType) throw new Error("device.deviceType is required");6869 const devices = await listPairedDevices();70 const idx = devices.findIndex((d) => d.deviceId === device.deviceId);71 if (idx >= 0) {72 devices[idx] = { ...devices[idx], ...device };73 } else {74 devices.push(device);75 }76 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });77}7879808182838485export async function setRelayUrlForAllPairedDevices(86 relayUrl: string,87): Promise<void> {88 const devices = await listPairedDevices();89 if (devices.length === 0) return;90 await chrome.storage.local.set({91 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),92 });93}949596979899100101102103104105106107108109export async function removePairedDeviceById(deviceId: string): Promise<void> {110 const devices = await listPairedDevices();111 const filtered = devices.filter((d) => d.deviceId !== deviceId);112 113 114 115 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });116 117 const device = devices.find((d) => d.deviceId === deviceId);118 if (device?.longTermPublicKeyHex) {119 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);120 }121}122123124125126127export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {128 try {129 const result = await chrome.storage.local.get(REVOCATIONS_KEY);130 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];131 if (Array.isArray(value)) return value.filter(isDeviceRevocation);132 return [];133 } catch {134 return [];135 }136}137138export async function isDeviceRevoked(deviceId: string): Promise<boolean> {139 return (await listDeviceRevocations()).some((r) => r.deviceId === deviceId);140}141142143144145146147export async function revokeDeviceById(148 deviceId: string,149 revokedByDeviceId: string,150 options: { now?: number } = {},151): Promise<DeviceRevocation | null> {152 const device = await findPairedDeviceById(deviceId);153 const existing = await listDeviceRevocations();154 if (existing.some((r) => r.deviceId === deviceId)) return null;155156 const record: DeviceRevocation = {157 deviceId,158 159 160 161 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),162 revokedAt: options.now ?? Date.now(),163 revokedByDeviceId,164 };165 await storeRevocations([...existing, record]);166 await removePairedDeviceById(deviceId);167 return record;168}169170171172173174175176177178export async function mergeDeviceRevocations(179 incoming: DeviceRevocation[],180): Promise<string[]> {181 const existing = await listDeviceRevocations();182 const known = new Set(existing.map((r) => r.deviceId));183 const fresh = incoming.filter((r) => !known.has(r.deviceId));184 if (fresh.length === 0) return [];185 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));186 return fresh.map((r) => r.deviceId);187}188189190191192193194195export async function clearDeviceRevocation(deviceId: string): Promise<void> {196 const existing = await listDeviceRevocations();197 const filtered = existing.filter((r) => r.deviceId !== deviceId);198 if (filtered.length === existing.length) return;199 await storeRevocations(filtered);200}201202async function storeRevocations(records: DeviceRevocation[]): Promise<void> {203 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });204}205206function isDeviceRevocation(x: unknown): x is DeviceRevocation {207 if (typeof x !== "object" || x === null) return false;208 const r = x as Record<string, unknown>;209 return (210 typeof r.deviceId === "string" &&211 !!r.deviceId &&212 typeof r.longTermPublicKeyHex === "string" &&213 typeof r.revokedAt === "number" &&214 typeof r.revokedByDeviceId === "string"215 );216}217218export async function clearPairedDevices(): Promise<void> {219 await chrome.storage.local.remove(REGISTRY_KEY);220}221222export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {223 const devices = await listPairedDevices();224 const idx = devices.findIndex((d) => d.deviceId === deviceId);225 if (idx < 0) return;226 devices[idx] = { ...devices[idx], lastSyncedAt: when };227 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });228}229230export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {231 try {232 const key = LAST_IP_KEY_PREFIX + pubkeyHex;233 const result = await chrome.storage.local.get(key);234 const value = (result as Record<string, unknown>)[key];235 return typeof value === "string" ? value : null;236 } catch {237 return null;238 }239}240241export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {242 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });243}244245export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {246 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);247}248249export async function getLastSyncAt(): Promise<number | null> {250 try {251 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);252 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];253 if (typeof value === "number") return value;254 if (typeof value === "string" && value !== "") {255 const n = Number.parseInt(value, 10);256 return Number.isFinite(n) ? n : null;257 }258 return null;259 } catch {260 return null;261 }262}263264export async function setLastSyncAt(when: number): Promise<void> {265 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });266}267268export interface SyncResult {269 entriesReceived: number;270 entriesSent: number;271}272273export async function getLastSyncResult(): Promise<SyncResult | null> {274 try {275 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);276 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];277 if (278 value &&279 typeof value === "object" &&280 typeof (value as Record<string, unknown>).entriesReceived === "number" &&281 typeof (value as Record<string, unknown>).entriesSent === "number"282 ) {283 return value as SyncResult;284 }285 return null;286 } catch {287 return null;288 }289}290291export async function setLastSyncResult(result: SyncResult): Promise<void> {292 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });293}294295function isPairedDevice(x: unknown): x is PairedDevice {296 if (typeof x !== "object" || x === null) return false;297 const r = x as Record<string, unknown>;298 if (typeof r.deviceId !== "string" || !r.deviceId) return false;299 if (typeof r.pairedAt !== "number") return false;300 if (301 r.rendezvousSecretHex !== undefined &&302 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))303 ) {304 return false;305 }306 if (r.relayUrl !== undefined) {307 if (typeof r.relayUrl !== "string") return false;308 try {309 if (new URL(r.relayUrl).protocol !== "wss:") return false;310 } catch {311 return false;312 }313 }314 return true;315}