12345678910111213141516171819import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";20import { constantTimeEqualHex } from "../shared/constant-time";21import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";22import type { SyncOutcome } from "../shared/sync-outcome";2324const REGISTRY_KEY = "peach_paired_devices_v3";25const REVOCATIONS_KEY = "peach_device_revocations_v1";2627const MAX_REVOCATIONS = 500;28const LAST_SYNC_AT_KEY = "peach_last_sync_at";29const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3031export async function listPairedDevices(): Promise<PairedDevice[]> {32 try {33 const result = await chrome.storage.local.get(REGISTRY_KEY);34 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];35 if (Array.isArray(devices)) return devices.filter(isPairedDevice);36 return [];37 } catch {38 return [];39 }40}4142export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {43 const devices = await listPairedDevices();44 return devices.find((d) => d.deviceId === deviceId) ?? null;45}4647export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {48 const devices = await listPairedDevices();49 return devices.find((d) =>50 d.longTermPublicKeyHex !== undefined &&51 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)52 ) ?? null;53}54555657585960export async function upsertPairedDevice(device: PairedDevice): Promise<void> {61 if (!device.deviceId) throw new Error("device.deviceId is required");62 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");63 if (!device.deviceName) throw new Error("device.deviceName is required");64 if (!device.deviceType) throw new Error("device.deviceType is required");6566 67 68 69 const stored = { ...device, deviceName: capDeviceName(device.deviceName) };70 const devices = await listPairedDevices();71 const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);72 if (idx >= 0) {73 devices[idx] = { ...devices[idx], ...stored };74 } else {75 devices.push(stored);76 }77 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });78 79 80 81 82}8384export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {85 const alias = name.trim();86 if (!alias) throw new Error("device name must not be empty");87 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");88 const devices = await listPairedDevices();89 const index = devices.findIndex((device) => device.deviceId === deviceId);90 if (index < 0) throw new Error("device_not_found");91 devices[index] = { ...devices[index], localAlias: alias };92 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });93}949596979899100101export async function setRelayUrlForAllPairedDevices(102 relayUrl: string,103): Promise<void> {104 const devices = await listPairedDevices();105 if (devices.length === 0) return;106 await chrome.storage.local.set({107 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),108 });109 110 111 112}113114115116117118119120121122123124125126127128export async function removePairedDeviceById(deviceId: string): Promise<void> {129 const devices = await listPairedDevices();130 const filtered = devices.filter((d) => d.deviceId !== deviceId);131 132 133 134 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });135 136 137}138139140141142143export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {144 try {145 const result = await chrome.storage.local.get(REVOCATIONS_KEY);146 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];147 if (Array.isArray(value)) return value.filter(isDeviceRevocation);148 return [];149 } catch {150 return [];151 }152}153154155156157158159160161162export async function isDeviceRevoked(163 deviceId: string,164 longTermPublicKeyHex?: string,165): Promise<boolean> {166 167 168 const key = longTermPublicKeyHex?.toLowerCase();169 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);170 return (await listDeviceRevocations()).some(171 (r) =>172 r.deviceId === deviceId ||173 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),174 );175}176177178179180181182183184export async function revokeDeviceById(185 deviceId: string,186 revokedByDeviceId: string,187 options: { now?: number } = {},188): Promise<DeviceRevocation | null> {189 const device = await findPairedDeviceById(deviceId);190 const existing = await listDeviceRevocations();191 if (existing.some((r) => r.deviceId === deviceId)) {192 193 194 195 196 if (device) await removePairedDeviceById(deviceId);197 return null;198 }199200 const record: DeviceRevocation = {201 deviceId,202 203 204 205 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),206 revokedAt: options.now ?? Date.now(),207 revokedByDeviceId,208 };209 await storeRevocations([...existing, record]);210 await removePairedDeviceById(deviceId);211 return record;212}213214215216217218219220221222export async function mergeDeviceRevocations(223 incoming: DeviceRevocation[],224): Promise<string[]> {225 const existing = await listDeviceRevocations();226 const known = new Set(existing.map((r) => r.deviceId));227 const fresh = incoming228 .filter((r) => !known.has(r.deviceId))229 230 231 232 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));233 if (fresh.length === 0) return [];234 await storeRevocations([...existing, ...fresh]);235 return fresh.map((r) => r.deviceId);236}237238239240241242243244245246export async function repairPairedRevocationOverlaps(): Promise<{247 revocationsCleared: number;248 pairedRowsRemoved: number;249}> {250 const devices = await listPairedDevices();251 const revocations = await listDeviceRevocations();252 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));253 const revocationIdsToClear = new Set<string>();254 const pairedIdsToRemove = new Set<string>();255256 for (const device of devices) {257 const revocation = revocationById.get(device.deviceId);258 if (!revocation) continue;259 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {260 revocationIdsToClear.add(device.deviceId);261 } else {262 pairedIdsToRemove.add(device.deviceId);263 }264 }265266 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {267 return { revocationsCleared: 0, pairedRowsRemoved: 0 };268 }269270 await chrome.storage.local.set({271 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),272 [REVOCATIONS_KEY]: revocations.filter(273 (record) => !revocationIdsToClear.has(record.deviceId),274 ),275 });276 return {277 revocationsCleared: revocationIdsToClear.size,278 pairedRowsRemoved: pairedIdsToRemove.size,279 };280}281282async function storeRevocations(records: DeviceRevocation[]): Promise<void> {283 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });284}285286287288289290291292293294295296297298299300301function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {302 if (records.length <= MAX_REVOCATIONS) return records;303 const byIssuer = new Map<string, DeviceRevocation[]>();304 for (const record of records) {305 const key = record.revokedByDeviceId ?? "";306 const list = byIssuer.get(key);307 if (list) list.push(record);308 else byIssuer.set(key, [record]);309 }310 const dropped = new Set<DeviceRevocation>();311 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {312 let victim: DeviceRevocation[] | undefined;313 for (const list of byIssuer.values()) {314 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);315 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {316 victim = list;317 }318 }319 if (!victim) break;320 const oldest = victim.find((r) => !dropped.has(r));321 if (!oldest) break;322 dropped.add(oldest);323 }324 return records.filter((r) => !dropped.has(r));325}326327function isDeviceRevocation(x: unknown): x is DeviceRevocation {328 if (typeof x !== "object" || x === null) return false;329 const r = x as Record<string, unknown>;330 return (331 typeof r.deviceId === "string" &&332 !!r.deviceId &&333 typeof r.longTermPublicKeyHex === "string" &&334 typeof r.revokedAt === "number" &&335 typeof r.revokedByDeviceId === "string"336 );337}338339export async function clearPairedDevices(): Promise<void> {340 await chrome.storage.local.remove(REGISTRY_KEY);341}342343export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {344 const devices = await listPairedDevices();345 const idx = devices.findIndex((d) => d.deviceId === deviceId);346 if (idx < 0) return;347 devices[idx] = { ...devices[idx], lastSyncedAt: when };348 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });349}350351export async function getLastSyncAt(): Promise<number | null> {352 try {353 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);354 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];355 if (typeof value === "number") return value;356 if (typeof value === "string" && value !== "") {357 const n = Number.parseInt(value, 10);358 return Number.isFinite(n) ? n : null;359 }360 return null;361 } catch {362 return null;363 }364}365366export async function setLastSyncAt(when: number): Promise<void> {367 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });368}369370371372373374375376export interface SyncResult {377 outcome: SyncOutcome;378 detail?: string;379 380 at: number;381 entriesReceived: number;382 entriesSent: number;383}384385export async function getLastSyncResult(): Promise<SyncResult | null> {386 try {387 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);388 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];389 if (390 value &&391 typeof value === "object" &&392 typeof (value as Record<string, unknown>).outcome === "string" &&393 typeof (value as Record<string, unknown>).at === "number" &&394 typeof (value as Record<string, unknown>).entriesReceived === "number" &&395 typeof (value as Record<string, unknown>).entriesSent === "number"396 ) {397 return value as SyncResult;398 }399 return null;400 } catch {401 return null;402 }403}404405export async function setLastSyncResult(result: SyncResult): Promise<void> {406 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });407}408409function isPairedDevice(x: unknown): x is PairedDevice {410 if (typeof x !== "object" || x === null) return false;411 const r = x as Record<string, unknown>;412 if (typeof r.deviceId !== "string" || !r.deviceId) return false;413 if (typeof r.pairedAt !== "number") return false;414 if (415 r.localAlias !== undefined &&416 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)417 ) {418 return false;419 }420 if (421 r.rendezvousSecretHex !== undefined &&422 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))423 ) {424 return false;425 }426 if (r.relayUrl !== undefined) {427 if (typeof r.relayUrl !== "string") return false;428 try {429 if (new URL(r.relayUrl).protocol !== "wss:") return false;430 } catch {431 return false;432 }433 }434 return true;435}