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}5455565758596061export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {62 await chrome.storage.local.set({63 [REGISTRY_KEY]: devices.map((device) => {64 if (!isValidSecret(device.rendezvousSecretHex)) return device;65 const scrubbed = { ...device };66 delete scrubbed.rendezvousSecretHex;67 return scrubbed;68 }),69 });70}7172function isValidSecret(value: unknown): value is string {73 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);74}75767778798081export async function upsertPairedDevice(device: PairedDevice): Promise<void> {82 if (!device.deviceId) throw new Error("device.deviceId is required");83 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");84 if (!device.deviceName) throw new Error("device.deviceName is required");85 if (!device.deviceType) throw new Error("device.deviceType is required");8687 88 89 90 const stored = { ...device, deviceName: capDeviceName(device.deviceName) };91 const devices = await listPairedDevices();92 const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);93 if (idx >= 0) {94 devices[idx] = { ...devices[idx], ...stored };95 } else {96 devices.push(stored);97 }98 await writePairedDevices(devices);99 100 101 102 103}104105export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {106 const alias = name.trim();107 if (!alias) throw new Error("device name must not be empty");108 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");109 const devices = await listPairedDevices();110 const index = devices.findIndex((device) => device.deviceId === deviceId);111 if (index < 0) throw new Error("device_not_found");112 devices[index] = { ...devices[index], localAlias: alias };113 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });114}115116117118119120121122export async function setRelayUrlForAllPairedDevices(123 relayUrl: string,124): Promise<void> {125 const devices = await listPairedDevices();126 if (devices.length === 0) return;127 await chrome.storage.local.set({128 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),129 });130 131 132 133}134135136137138139140141142143144145146147148149export async function removePairedDeviceById(deviceId: string): Promise<void> {150 const devices = await listPairedDevices();151 const filtered = devices.filter((d) => d.deviceId !== deviceId);152 153 154 155 await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });156 157 158}159160161162163164export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {165 try {166 const result = await chrome.storage.local.get(REVOCATIONS_KEY);167 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];168 if (Array.isArray(value)) return value.filter(isDeviceRevocation);169 return [];170 } catch {171 return [];172 }173}174175176177178179180181182183export async function isDeviceRevoked(184 deviceId: string,185 longTermPublicKeyHex?: string,186): Promise<boolean> {187 188 189 const key = longTermPublicKeyHex?.toLowerCase();190 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);191 return (await listDeviceRevocations()).some(192 (r) =>193 r.deviceId === deviceId ||194 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),195 );196}197198199200201202203204205export async function revokeDeviceById(206 deviceId: string,207 revokedByDeviceId: string,208 options: { now?: number } = {},209): Promise<DeviceRevocation | null> {210 const device = await findPairedDeviceById(deviceId);211 const existing = await listDeviceRevocations();212 if (existing.some((r) => r.deviceId === deviceId)) {213 214 215 216 217 if (device) await removePairedDeviceById(deviceId);218 return null;219 }220221 const record: DeviceRevocation = {222 deviceId,223 224 225 226 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),227 revokedAt: options.now ?? Date.now(),228 revokedByDeviceId,229 };230 await storeRevocations([...existing, record]);231 await removePairedDeviceById(deviceId);232 return record;233}234235236237238239240241242243export async function mergeDeviceRevocations(244 incoming: DeviceRevocation[],245): Promise<string[]> {246 const existing = await listDeviceRevocations();247 const known = new Set(existing.map((r) => r.deviceId));248 const fresh = incoming249 .filter((r) => !known.has(r.deviceId))250 251 252 253 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));254 if (fresh.length === 0) return [];255 await storeRevocations([...existing, ...fresh]);256 return fresh.map((r) => r.deviceId);257}258259260261262263264265266267export async function repairPairedRevocationOverlaps(): Promise<{268 revocationsCleared: number;269 pairedRowsRemoved: number;270}> {271 const devices = await listPairedDevices();272 const revocations = await listDeviceRevocations();273 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));274 const revocationIdsToClear = new Set<string>();275 const pairedIdsToRemove = new Set<string>();276277 for (const device of devices) {278 const revocation = revocationById.get(device.deviceId);279 if (!revocation) continue;280 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {281 revocationIdsToClear.add(device.deviceId);282 } else {283 pairedIdsToRemove.add(device.deviceId);284 }285 }286287 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {288 return { revocationsCleared: 0, pairedRowsRemoved: 0 };289 }290291 await chrome.storage.local.set({292 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),293 [REVOCATIONS_KEY]: revocations.filter(294 (record) => !revocationIdsToClear.has(record.deviceId),295 ),296 });297 return {298 revocationsCleared: revocationIdsToClear.size,299 pairedRowsRemoved: pairedIdsToRemove.size,300 };301}302303async function storeRevocations(records: DeviceRevocation[]): Promise<void> {304 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });305}306307308309310311312313314315316317318319320321322function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {323 if (records.length <= MAX_REVOCATIONS) return records;324 const byIssuer = new Map<string, DeviceRevocation[]>();325 for (const record of records) {326 const key = record.revokedByDeviceId ?? "";327 const list = byIssuer.get(key);328 if (list) list.push(record);329 else byIssuer.set(key, [record]);330 }331 const dropped = new Set<DeviceRevocation>();332 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {333 let victim: DeviceRevocation[] | undefined;334 for (const list of byIssuer.values()) {335 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);336 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {337 victim = list;338 }339 }340 if (!victim) break;341 const oldest = victim.find((r) => !dropped.has(r));342 if (!oldest) break;343 dropped.add(oldest);344 }345 return records.filter((r) => !dropped.has(r));346}347348function isDeviceRevocation(x: unknown): x is DeviceRevocation {349 if (typeof x !== "object" || x === null) return false;350 const r = x as Record<string, unknown>;351 return (352 typeof r.deviceId === "string" &&353 !!r.deviceId &&354 typeof r.longTermPublicKeyHex === "string" &&355 typeof r.revokedAt === "number" &&356 typeof r.revokedByDeviceId === "string"357 );358}359360export async function clearPairedDevices(): Promise<void> {361 await chrome.storage.local.remove(REGISTRY_KEY);362}363364export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {365 const devices = await listPairedDevices();366 const idx = devices.findIndex((d) => d.deviceId === deviceId);367 if (idx < 0) return;368 devices[idx] = { ...devices[idx], lastSyncedAt: when };369 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });370}371372export async function getLastSyncAt(): Promise<number | null> {373 try {374 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);375 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];376 if (typeof value === "number") return value;377 if (typeof value === "string" && value !== "") {378 const n = Number.parseInt(value, 10);379 return Number.isFinite(n) ? n : null;380 }381 return null;382 } catch {383 return null;384 }385}386387export async function setLastSyncAt(when: number): Promise<void> {388 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });389}390391392393394395396397export interface SyncResult {398 outcome: SyncOutcome;399 detail?: string;400 401 at: number;402 entriesReceived: number;403 entriesSent: number;404}405406export async function getLastSyncResult(): Promise<SyncResult | null> {407 try {408 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);409 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];410 if (411 value &&412 typeof value === "object" &&413 typeof (value as Record<string, unknown>).outcome === "string" &&414 typeof (value as Record<string, unknown>).at === "number" &&415 typeof (value as Record<string, unknown>).entriesReceived === "number" &&416 typeof (value as Record<string, unknown>).entriesSent === "number"417 ) {418 return value as SyncResult;419 }420 return null;421 } catch {422 return null;423 }424}425426export async function setLastSyncResult(result: SyncResult): Promise<void> {427 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });428}429430function isPairedDevice(x: unknown): x is PairedDevice {431 if (typeof x !== "object" || x === null) return false;432 const r = x as Record<string, unknown>;433 if (typeof r.deviceId !== "string" || !r.deviceId) return false;434 if (typeof r.pairedAt !== "number") return false;435 if (436 r.localAlias !== undefined &&437 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)438 ) {439 return false;440 }441 if (442 r.rendezvousSecretHex !== undefined &&443 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))444 ) {445 return false;446 }447 if (r.relayUrl !== undefined) {448 if (typeof r.relayUrl !== "string") return false;449 try {450 if (new URL(r.relayUrl).protocol !== "wss:") return false;451 } catch {452 return false;453 }454 }455 return true;456}