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}8889export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {90 const alias = name.trim();91 if (!alias) throw new Error("device name must not be empty");92 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");93 const devices = await listPairedDevices();94 const index = devices.findIndex((device) => device.deviceId === deviceId);95 if (index < 0) throw new Error("device_not_found");96 devices[index] = { ...devices[index], localAlias: alias };97 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });98}99100101102103104105106export async function setRelayUrlForAllPairedDevices(107 relayUrl: string,108): Promise<void> {109 const devices = await listPairedDevices();110 if (devices.length === 0) return;111 await chrome.storage.local.set({112 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),113 });114 115 116 117}118119120121122123124125126127128129130131132133export async function removePairedDeviceById(deviceId: string): Promise<void> {134 const devices = await listPairedDevices();135 const filtered = devices.filter((d) => d.deviceId !== deviceId);136 137 138 139 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });140 141 const device = devices.find((d) => d.deviceId === deviceId);142 if (device?.longTermPublicKeyHex) {143 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);144 }145 146 147}148149150151152153export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {154 try {155 const result = await chrome.storage.local.get(REVOCATIONS_KEY);156 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];157 if (Array.isArray(value)) return value.filter(isDeviceRevocation);158 return [];159 } catch {160 return [];161 }162}163164165166167168169170171172export async function isDeviceRevoked(173 deviceId: string,174 longTermPublicKeyHex?: string,175): Promise<boolean> {176 177 178 const key = longTermPublicKeyHex?.toLowerCase();179 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);180 return (await listDeviceRevocations()).some(181 (r) =>182 r.deviceId === deviceId ||183 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),184 );185}186187188189190191192193194export async function revokeDeviceById(195 deviceId: string,196 revokedByDeviceId: string,197 options: { now?: number } = {},198): Promise<DeviceRevocation | null> {199 const device = await findPairedDeviceById(deviceId);200 const existing = await listDeviceRevocations();201 if (existing.some((r) => r.deviceId === deviceId)) {202 203 204 205 206 if (device) await removePairedDeviceById(deviceId);207 return null;208 }209210 const record: DeviceRevocation = {211 deviceId,212 213 214 215 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),216 revokedAt: options.now ?? Date.now(),217 revokedByDeviceId,218 };219 await storeRevocations([...existing, record]);220 await removePairedDeviceById(deviceId);221 return record;222}223224225226227228229230231232export async function mergeDeviceRevocations(233 incoming: DeviceRevocation[],234): Promise<string[]> {235 const existing = await listDeviceRevocations();236 const known = new Set(existing.map((r) => r.deviceId));237 const fresh = incoming238 .filter((r) => !known.has(r.deviceId))239 240 241 242 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));243 if (fresh.length === 0) return [];244 await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));245 return fresh.map((r) => r.deviceId);246}247248249250251252253254export async function clearDeviceRevocation(deviceId: string): Promise<void> {255 const existing = await listDeviceRevocations();256 const filtered = existing.filter((r) => r.deviceId !== deviceId);257 if (filtered.length === existing.length) return;258 await storeRevocations(filtered);259}260261262263264265266267268269export async function repairPairedRevocationOverlaps(): Promise<{270 revocationsCleared: number;271 pairedRowsRemoved: number;272}> {273 const devices = await listPairedDevices();274 const revocations = await listDeviceRevocations();275 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));276 const revocationIdsToClear = new Set<string>();277 const pairedIdsToRemove = new Set<string>();278279 for (const device of devices) {280 const revocation = revocationById.get(device.deviceId);281 if (!revocation) continue;282 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {283 revocationIdsToClear.add(device.deviceId);284 } else {285 pairedIdsToRemove.add(device.deviceId);286 }287 }288289 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {290 return { revocationsCleared: 0, pairedRowsRemoved: 0 };291 }292293 await chrome.storage.local.set({294 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),295 [REVOCATIONS_KEY]: revocations.filter(296 (record) => !revocationIdsToClear.has(record.deviceId),297 ),298 });299 return {300 revocationsCleared: revocationIdsToClear.size,301 pairedRowsRemoved: pairedIdsToRemove.size,302 };303}304305async function storeRevocations(records: DeviceRevocation[]): Promise<void> {306 await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });307}308309function isDeviceRevocation(x: unknown): x is DeviceRevocation {310 if (typeof x !== "object" || x === null) return false;311 const r = x as Record<string, unknown>;312 return (313 typeof r.deviceId === "string" &&314 !!r.deviceId &&315 typeof r.longTermPublicKeyHex === "string" &&316 typeof r.revokedAt === "number" &&317 typeof r.revokedByDeviceId === "string"318 );319}320321export async function clearPairedDevices(): Promise<void> {322 await chrome.storage.local.remove(REGISTRY_KEY);323}324325export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {326 const devices = await listPairedDevices();327 const idx = devices.findIndex((d) => d.deviceId === deviceId);328 if (idx < 0) return;329 devices[idx] = { ...devices[idx], lastSyncedAt: when };330 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });331}332333export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {334 try {335 const key = LAST_IP_KEY_PREFIX + pubkeyHex;336 const result = await chrome.storage.local.get(key);337 const value = (result as Record<string, unknown>)[key];338 return typeof value === "string" ? value : null;339 } catch {340 return null;341 }342}343344export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {345 await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });346}347348export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {349 await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);350}351352export async function getLastSyncAt(): Promise<number | null> {353 try {354 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);355 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];356 if (typeof value === "number") return value;357 if (typeof value === "string" && value !== "") {358 const n = Number.parseInt(value, 10);359 return Number.isFinite(n) ? n : null;360 }361 return null;362 } catch {363 return null;364 }365}366367export async function setLastSyncAt(when: number): Promise<void> {368 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });369}370371372373374375376377export interface SyncResult {378 outcome: SyncOutcome;379 detail?: string;380 381 at: number;382 entriesReceived: number;383 entriesSent: number;384}385386export async function getLastSyncResult(): Promise<SyncResult | null> {387 try {388 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);389 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];390 if (391 value &&392 typeof value === "object" &&393 typeof (value as Record<string, unknown>).outcome === "string" &&394 typeof (value as Record<string, unknown>).at === "number" &&395 typeof (value as Record<string, unknown>).entriesReceived === "number" &&396 typeof (value as Record<string, unknown>).entriesSent === "number"397 ) {398 return value as SyncResult;399 }400 return null;401 } catch {402 return null;403 }404}405406export async function setLastSyncResult(result: SyncResult): Promise<void> {407 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });408}409410function isPairedDevice(x: unknown): x is PairedDevice {411 if (typeof x !== "object" || x === null) return false;412 const r = x as Record<string, unknown>;413 if (typeof r.deviceId !== "string" || !r.deviceId) return false;414 if (typeof r.pairedAt !== "number") return false;415 if (416 r.localAlias !== undefined &&417 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)418 ) {419 return false;420 }421 if (422 r.rendezvousSecretHex !== undefined &&423 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))424 ) {425 return false;426 }427 if (r.relayUrl !== undefined) {428 if (typeof r.relayUrl !== "string") return false;429 try {430 if (new URL(r.relayUrl).protocol !== "wss:") return false;431 } catch {432 return false;433 }434 }435 return true;436}