1234567891011121314151617181920212223import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";24import { constantTimeEqualHex } from "../shared/constant-time";25import { capDeviceName, 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 72 73 74 const stored = { ...device, deviceName: capDeviceName(device.deviceName) };75 const devices = await listPairedDevices();76 const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);77 if (idx >= 0) {78 devices[idx] = { ...devices[idx], ...stored };79 } else {80 devices.push(stored);81 }82 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });83 84 85 86 87}8889909192939495export async function setRelayUrlForAllPairedDevices(96 relayUrl: string,97): Promise<void> {98 const devices = await listPairedDevices();99 if (devices.length === 0) return;100 await chrome.storage.local.set({101 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),102 });103 104 105 106}107108109110111112113114115116117118119120121122export async function removePairedDeviceById(deviceId: string): Promise<void> {123 const devices = await listPairedDevices();124 const filtered = devices.filter((d) => d.deviceId !== deviceId);125 126 127 128 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });129 130 const device = devices.find((d) => d.deviceId === deviceId);131 if (device?.longTermPublicKeyHex) {132 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);133 }134 135 136}137138139140141142export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {143 try {144 const result = await chrome.storage.local.get(REVOCATIONS_KEY);145 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];146 if (Array.isArray(value)) return value.filter(isDeviceRevocation);147 return [];148 } catch {149 return [];150 }151}152153154155156157158159160161export async function isDeviceRevoked(162 deviceId: string,163 longTermPublicKeyHex?: string,164): Promise<boolean> {165 166 167 const key = longTermPublicKeyHex?.toLowerCase();168 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);169 return (await listDeviceRevocations()).some(170 (r) =>171 r.deviceId === deviceId ||172 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),173 );174}175176177178179180181182183export async function revokeDeviceById(184 deviceId: string,185 revokedByDeviceId: string,186 options: { now?: number } = {},187): Promise<DeviceRevocation | null> {188 const device = await findPairedDeviceById(deviceId);189 const existing = await listDeviceRevocations();190 if (existing.some((r) => r.deviceId === deviceId)) {191 192 193 194 195 if (device) await removePairedDeviceById(deviceId);196 return null;197 }198199 const record: DeviceRevocation = {200 deviceId,201 202 203 204 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),205 revokedAt: options.now ?? Date.now(),206 revokedByDeviceId,207 };208 await storeRevocations([...existing, record]);209 await removePairedDeviceById(deviceId);210 return record;211}212213214215216217218219220221export async function mergeDeviceRevocations(222 incoming: DeviceRevocation[],223): Promise<string[]> {224 const existing = await listDeviceRevocations();225 const known = new Set(existing.map((r) => r.deviceId));226 const fresh = incoming227 .filter((r) => !known.has(r.deviceId))228 229 230 231 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));232 if (fresh.length === 0) return [];233 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));234 return fresh.map((r) => r.deviceId);235}236237238239240241242243export async function clearDeviceRevocation(deviceId: string): Promise<void> {244 const existing = await listDeviceRevocations();245 const filtered = existing.filter((r) => r.deviceId !== deviceId);246 if (filtered.length === existing.length) return;247 await storeRevocations(filtered);248}249250251252253254255256257258export async function repairPairedRevocationOverlaps(): Promise<{259 revocationsCleared: number;260 pairedRowsRemoved: number;261}> {262 const devices = await listPairedDevices();263 const revocations = await listDeviceRevocations();264 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));265 const revocationIdsToClear = new Set<string>();266 const pairedIdsToRemove = new Set<string>();267268 for (const device of devices) {269 const revocation = revocationById.get(device.deviceId);270 if (!revocation) continue;271 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {272 revocationIdsToClear.add(device.deviceId);273 } else {274 pairedIdsToRemove.add(device.deviceId);275 }276 }277278 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {279 return { revocationsCleared: 0, pairedRowsRemoved: 0 };280 }281282 await chrome.storage.local.set({283 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),284 [REVOCATIONS_KEY]: revocations.filter(285 (record) => !revocationIdsToClear.has(record.deviceId),286 ),287 });288 return {289 revocationsCleared: revocationIdsToClear.size,290 pairedRowsRemoved: pairedIdsToRemove.size,291 };292}293294async function storeRevocations(records: DeviceRevocation[]): Promise<void> {295 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });296}297298function isDeviceRevocation(x: unknown): x is DeviceRevocation {299 if (typeof x !== "object" || x === null) return false;300 const r = x as Record<string, unknown>;301 return (302 typeof r.deviceId === "string" &&303 !!r.deviceId &&304 typeof r.longTermPublicKeyHex === "string" &&305 typeof r.revokedAt === "number" &&306 typeof r.revokedByDeviceId === "string"307 );308}309310export async function clearPairedDevices(): Promise<void> {311 await chrome.storage.local.remove(REGISTRY_KEY);312}313314export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {315 const devices = await listPairedDevices();316 const idx = devices.findIndex((d) => d.deviceId === deviceId);317 if (idx < 0) return;318 devices[idx] = { ...devices[idx], lastSyncedAt: when };319 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });320}321322export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {323 try {324 const key = LAST_IP_KEY_PREFIX + pubkeyHex;325 const result = await chrome.storage.local.get(key);326 const value = (result as Record<string, unknown>)[key];327 return typeof value === "string" ? value : null;328 } catch {329 return null;330 }331}332333export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {334 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });335}336337export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {338 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);339}340341export async function getLastSyncAt(): Promise<number | null> {342 try {343 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);344 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];345 if (typeof value === "number") return value;346 if (typeof value === "string" && value !== "") {347 const n = Number.parseInt(value, 10);348 return Number.isFinite(n) ? n : null;349 }350 return null;351 } catch {352 return null;353 }354}355356export async function setLastSyncAt(when: number): Promise<void> {357 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });358}359360361362363364365366export interface SyncResult {367 outcome: SyncOutcome;368 detail?: string;369 370 at: number;371 entriesReceived: number;372 entriesSent: number;373}374375export async function getLastSyncResult(): Promise<SyncResult | null> {376 try {377 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);378 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];379 if (380 value &&381 typeof value === "object" &&382 typeof (value as Record<string, unknown>).outcome === "string" &&383 typeof (value as Record<string, unknown>).at === "number" &&384 typeof (value as Record<string, unknown>).entriesReceived === "number" &&385 typeof (value as Record<string, unknown>).entriesSent === "number"386 ) {387 return value as SyncResult;388 }389 return null;390 } catch {391 return null;392 }393}394395export async function setLastSyncResult(result: SyncResult): Promise<void> {396 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });397}398399function isPairedDevice(x: unknown): x is PairedDevice {400 if (typeof x !== "object" || x === null) return false;401 const r = x as Record<string, unknown>;402 if (typeof r.deviceId !== "string" || !r.deviceId) return false;403 if (typeof r.pairedAt !== "number") return false;404 if (405 r.rendezvousSecretHex !== undefined &&406 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))407 ) {408 return false;409 }410 if (r.relayUrl !== undefined) {411 if (typeof r.relayUrl !== "string") return false;412 try {413 if (new URL(r.relayUrl).protocol !== "wss:") return false;414 } catch {415 return false;416 }417 }418 return true;419}