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 80 81 82 83}8485868788899091export async function setRelayUrlForAllPairedDevices(92 relayUrl: string,93): Promise<void> {94 const devices = await listPairedDevices();95 if (devices.length === 0) return;96 await chrome.storage.local.set({97 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),98 });99 100 101 102}103104105106107108109110111112113114115116117118export async function removePairedDeviceById(deviceId: string): Promise<void> {119 const devices = await listPairedDevices();120 const filtered = devices.filter((d) => d.deviceId !== deviceId);121 122 123 124 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });125 126 const device = devices.find((d) => d.deviceId === deviceId);127 if (device?.longTermPublicKeyHex) {128 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);129 }130 131 132}133134135136137138export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {139 try {140 const result = await chrome.storage.local.get(REVOCATIONS_KEY);141 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];142 if (Array.isArray(value)) return value.filter(isDeviceRevocation);143 return [];144 } catch {145 return [];146 }147}148149150151152153154155156157export async function isDeviceRevoked(158 deviceId: string,159 longTermPublicKeyHex?: string,160): Promise<boolean> {161 162 163 const key = longTermPublicKeyHex?.toLowerCase();164 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);165 return (await listDeviceRevocations()).some(166 (r) =>167 r.deviceId === deviceId ||168 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),169 );170}171172173174175176177178179export async function revokeDeviceById(180 deviceId: string,181 revokedByDeviceId: string,182 options: { now?: number } = {},183): Promise<DeviceRevocation | null> {184 const device = await findPairedDeviceById(deviceId);185 const existing = await listDeviceRevocations();186 if (existing.some((r) => r.deviceId === deviceId)) {187 188 189 190 191 if (device) await removePairedDeviceById(deviceId);192 return null;193 }194195 const record: DeviceRevocation = {196 deviceId,197 198 199 200 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),201 revokedAt: options.now ?? Date.now(),202 revokedByDeviceId,203 };204 await storeRevocations([...existing, record]);205 await removePairedDeviceById(deviceId);206 return record;207}208209210211212213214215216217export async function mergeDeviceRevocations(218 incoming: DeviceRevocation[],219): Promise<string[]> {220 const existing = await listDeviceRevocations();221 const known = new Set(existing.map((r) => r.deviceId));222 const fresh = incoming223 .filter((r) => !known.has(r.deviceId))224 225 226 227 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));228 if (fresh.length === 0) return [];229 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));230 return fresh.map((r) => r.deviceId);231}232233234235236237238239export async function clearDeviceRevocation(deviceId: string): Promise<void> {240 const existing = await listDeviceRevocations();241 const filtered = existing.filter((r) => r.deviceId !== deviceId);242 if (filtered.length === existing.length) return;243 await storeRevocations(filtered);244}245246247248249250251252253254export async function repairPairedRevocationOverlaps(): Promise<{255 revocationsCleared: number;256 pairedRowsRemoved: number;257}> {258 const devices = await listPairedDevices();259 const revocations = await listDeviceRevocations();260 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));261 const revocationIdsToClear = new Set<string>();262 const pairedIdsToRemove = new Set<string>();263264 for (const device of devices) {265 const revocation = revocationById.get(device.deviceId);266 if (!revocation) continue;267 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {268 revocationIdsToClear.add(device.deviceId);269 } else {270 pairedIdsToRemove.add(device.deviceId);271 }272 }273274 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {275 return { revocationsCleared: 0, pairedRowsRemoved: 0 };276 }277278 await chrome.storage.local.set({279 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),280 [REVOCATIONS_KEY]: revocations.filter(281 (record) => !revocationIdsToClear.has(record.deviceId),282 ),283 });284 return {285 revocationsCleared: revocationIdsToClear.size,286 pairedRowsRemoved: pairedIdsToRemove.size,287 };288}289290async function storeRevocations(records: DeviceRevocation[]): Promise<void> {291 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });292}293294function isDeviceRevocation(x: unknown): x is DeviceRevocation {295 if (typeof x !== "object" || x === null) return false;296 const r = x as Record<string, unknown>;297 return (298 typeof r.deviceId === "string" &&299 !!r.deviceId &&300 typeof r.longTermPublicKeyHex === "string" &&301 typeof r.revokedAt === "number" &&302 typeof r.revokedByDeviceId === "string"303 );304}305306export async function clearPairedDevices(): Promise<void> {307 await chrome.storage.local.remove(REGISTRY_KEY);308}309310export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {311 const devices = await listPairedDevices();312 const idx = devices.findIndex((d) => d.deviceId === deviceId);313 if (idx < 0) return;314 devices[idx] = { ...devices[idx], lastSyncedAt: when };315 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });316}317318export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {319 try {320 const key = LAST_IP_KEY_PREFIX + pubkeyHex;321 const result = await chrome.storage.local.get(key);322 const value = (result as Record<string, unknown>)[key];323 return typeof value === "string" ? value : null;324 } catch {325 return null;326 }327}328329export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {330 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });331}332333export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {334 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);335}336337export async function getLastSyncAt(): Promise<number | null> {338 try {339 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);340 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];341 if (typeof value === "number") return value;342 if (typeof value === "string" && value !== "") {343 const n = Number.parseInt(value, 10);344 return Number.isFinite(n) ? n : null;345 }346 return null;347 } catch {348 return null;349 }350}351352export async function setLastSyncAt(when: number): Promise<void> {353 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });354}355356357358359360361362export interface SyncResult {363 outcome: SyncOutcome;364 detail?: string;365 366 at: number;367 entriesReceived: number;368 entriesSent: number;369}370371export async function getLastSyncResult(): Promise<SyncResult | null> {372 try {373 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);374 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];375 if (376 value &&377 typeof value === "object" &&378 typeof (value as Record<string, unknown>).outcome === "string" &&379 typeof (value as Record<string, unknown>).at === "number" &&380 typeof (value as Record<string, unknown>).entriesReceived === "number" &&381 typeof (value as Record<string, unknown>).entriesSent === "number"382 ) {383 return value as SyncResult;384 }385 return null;386 } catch {387 return null;388 }389}390391export async function setLastSyncResult(result: SyncResult): Promise<void> {392 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });393}394395function isPairedDevice(x: unknown): x is PairedDevice {396 if (typeof x !== "object" || x === null) return false;397 const r = x as Record<string, unknown>;398 if (typeof r.deviceId !== "string" || !r.deviceId) return false;399 if (typeof r.pairedAt !== "number") return false;400 if (401 r.rendezvousSecretHex !== undefined &&402 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))403 ) {404 return false;405 }406 if (r.relayUrl !== undefined) {407 if (typeof r.relayUrl !== "string") return false;408 try {409 if (new URL(r.relayUrl).protocol !== "wss:") return false;410 } catch {411 return false;412 }413 }414 return true;415}