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";30let deviceMutationTail: Promise<void> = Promise.resolve();3132function enqueueDeviceMutation<T>(mutation: () => Promise<T>): Promise<T> {33 const result = deviceMutationTail.then(mutation, mutation);34 deviceMutationTail = result.then(() => undefined, () => undefined);35 return result;36}3738async function listPairedDevicesRaw(): Promise<PairedDevice[]> {39 try {40 const result = await chrome.storage.local.get(REGISTRY_KEY);41 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];42 if (Array.isArray(devices)) return devices.filter(isPairedDevice);43 return [];44 } catch {45 return [];46 }47}4849export async function listPairedDevices(): Promise<PairedDevice[]> {50 await deviceMutationTail;51 return listPairedDevicesRaw();52}5354export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {55 const devices = await listPairedDevices();56 return devices.find((d) => d.deviceId === deviceId) ?? null;57}5859export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {60 const devices = await listPairedDevices();61 return devices.find((d) =>62 d.longTermPublicKeyHex !== undefined &&63 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)64 ) ?? null;65}6667686970717273export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {74 await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));75}7677async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {78 await chrome.storage.local.set({79 [REGISTRY_KEY]: devices.map((device) => {80 if (!isValidSecret(device.rendezvousSecretHex)) return device;81 const scrubbed = { ...device };82 delete scrubbed.rendezvousSecretHex;83 return scrubbed;84 }),85 });86}8788function isValidSecret(value: unknown): value is string {89 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);90}91929394959697export async function upsertPairedDevice(device: PairedDevice): Promise<void> {98 if (!device.deviceId) throw new Error("device.deviceId is required");99 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");100 if (!device.deviceName) throw new Error("device.deviceName is required");101 if (!device.deviceType) throw new Error("device.deviceType is required");102103 104 105 106 await enqueueDeviceMutation(async () => {107 const stored = { ...device, deviceName: capDeviceName(device.deviceName!) };108 const devices = await listPairedDevicesRaw();109 const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);110 if (idx >= 0) devices[idx] = { ...devices[idx], ...stored };111 else devices.push(stored);112 await writePairedDevicesRaw(devices);113 });114 115 116 117 118}119120export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {121 const alias = name.trim();122 if (!alias) throw new Error("device name must not be empty");123 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");124 await enqueueDeviceMutation(async () => {125 const devices = await listPairedDevicesRaw();126 const index = devices.findIndex((device) => device.deviceId === deviceId);127 if (index < 0) throw new Error("device_not_found");128 devices[index] = { ...devices[index], localAlias: alias };129 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });130 });131}132133134135136137138139export async function setRelayUrlForAllPairedDevices(140 relayUrl: string,141): Promise<void> {142 await enqueueDeviceMutation(async () => {143 const devices = await listPairedDevicesRaw();144 if (devices.length === 0) return;145 await chrome.storage.local.set({146 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),147 });148 });149 150 151 152}153154155156157158159160161162163164165166167168export async function removePairedDeviceById(deviceId: string): Promise<void> {169 await enqueueDeviceMutation(async () => {170 const devices = await listPairedDevicesRaw();171 await chrome.storage.local.set({172 [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),173 });174 });175 176 177}178179180181182183async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {184 try {185 const result = await chrome.storage.local.get(REVOCATIONS_KEY);186 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];187 if (Array.isArray(value)) return value.filter(isDeviceRevocation);188 return [];189 } catch {190 return [];191 }192}193194export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {195 await deviceMutationTail;196 return listDeviceRevocationsRaw();197}198199200201202203204205206207export async function isDeviceRevoked(208 deviceId: string,209 longTermPublicKeyHex?: string,210): Promise<boolean> {211 212 213 const key = longTermPublicKeyHex?.toLowerCase();214 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);215 await deviceMutationTail;216 return (await listDeviceRevocationsRaw()).some(217 (r) =>218 r.deviceId === deviceId ||219 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),220 );221}222223224225226227228229230export async function revokeDeviceById(231 deviceId: string,232 revokedByDeviceId: string,233 options: { now?: number } = {},234): Promise<DeviceRevocation | null> {235 return enqueueDeviceMutation(async () => {236 const devices = await listPairedDevicesRaw();237 const device = devices.find((candidate) => candidate.deviceId === deviceId);238 const existing = await listDeviceRevocationsRaw();239 if (existing.some((r) => r.deviceId === deviceId)) {240 241 242 243 244 if (device) await chrome.storage.local.set({245 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),246 });247 return null;248 }249250 const record: DeviceRevocation = {251 deviceId,252 253 254 255 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),256 revokedAt: options.now ?? Date.now(),257 revokedByDeviceId,258 };259 await chrome.storage.local.set({260 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),261 [REVOCATIONS_KEY]: capRevocations([...existing, record]),262 });263 return record;264 });265}266267268269270271272export async function clearDeviceRevocation(deviceId: string): Promise<void> {273 await enqueueDeviceMutation(async () => {274 const existing = await listDeviceRevocationsRaw();275 const filtered = existing.filter((r) => r.deviceId !== deviceId);276 if (filtered.length !== existing.length) {277 await storeRevocationsRaw(filtered);278 }279 });280}281282283284285286287export async function hasAdoptionIdentityConflict(288 deviceId: string,289 longTermPublicKeyHex: string,290): Promise<boolean> {291 await deviceMutationTail;292 const devices = await listPairedDevicesRaw();293 if (devices.some((d) => d.deviceId === deviceId)) return true;294 const key = longTermPublicKeyHex.toLowerCase();295 return (await listDeviceRevocationsRaw()).some((record) =>296 record.deviceId === deviceId ||297 (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)298 );299}300301302303304305306307308309export async function mergeDeviceRevocations(310 incoming: DeviceRevocation[],311): Promise<string[]> {312 return enqueueDeviceMutation(async () => {313 const existing = await listDeviceRevocationsRaw();314 const known = new Set(existing.map((r) => r.deviceId));315 const fresh = incoming316 .filter((r) => !known.has(r.deviceId))317 318 319 320 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));321 if (fresh.length === 0) return [];322 await storeRevocationsRaw([...existing, ...fresh]);323 return fresh.map((r) => r.deviceId);324 });325}326327328329330331332333334335export async function repairPairedRevocationOverlaps(): Promise<{336 revocationsCleared: number;337 pairedRowsRemoved: number;338}> {339 return enqueueDeviceMutation(async () => {340 const devices = await listPairedDevicesRaw();341 const revocations = await listDeviceRevocationsRaw();342 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));343 const revocationIdsToClear = new Set<string>();344 const pairedIdsToRemove = new Set<string>();345346 for (const device of devices) {347 const revocation = revocationById.get(device.deviceId);348 if (!revocation) continue;349 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {350 revocationIdsToClear.add(device.deviceId);351 } else {352 pairedIdsToRemove.add(device.deviceId);353 }354 }355356 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {357 return { revocationsCleared: 0, pairedRowsRemoved: 0 };358 }359360 await chrome.storage.local.set({361 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),362 [REVOCATIONS_KEY]: revocations.filter(363 (record) => !revocationIdsToClear.has(record.deviceId),364 ),365 });366 return {367 revocationsCleared: revocationIdsToClear.size,368 pairedRowsRemoved: pairedIdsToRemove.size,369 };370 });371}372373async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {374 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });375}376377378379380381382383384385386387388389390391392function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {393 if (records.length <= MAX_REVOCATIONS) return records;394 const byIssuer = new Map<string, DeviceRevocation[]>();395 for (const record of records) {396 const key = record.revokedByDeviceId ?? "";397 const list = byIssuer.get(key);398 if (list) list.push(record);399 else byIssuer.set(key, [record]);400 }401 const dropped = new Set<DeviceRevocation>();402 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {403 let victim: DeviceRevocation[] | undefined;404 for (const list of byIssuer.values()) {405 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);406 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {407 victim = list;408 }409 }410 if (!victim) break;411 const oldest = victim.find((r) => !dropped.has(r));412 if (!oldest) break;413 dropped.add(oldest);414 }415 return records.filter((r) => !dropped.has(r));416}417418function isDeviceRevocation(x: unknown): x is DeviceRevocation {419 if (typeof x !== "object" || x === null) return false;420 const r = x as Record<string, unknown>;421 return (422 typeof r.deviceId === "string" &&423 !!r.deviceId &&424 typeof r.longTermPublicKeyHex === "string" &&425 typeof r.revokedAt === "number" &&426 typeof r.revokedByDeviceId === "string"427 );428}429430export async function clearPairedDevices(): Promise<void> {431 await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));432}433434export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {435 await enqueueDeviceMutation(async () => {436 const devices = await listPairedDevicesRaw();437 const idx = devices.findIndex((d) => d.deviceId === deviceId);438 if (idx < 0) return;439 devices[idx] = { ...devices[idx], lastSyncedAt: when };440 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });441 });442}443444export async function getLastSyncAt(): Promise<number | null> {445 try {446 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);447 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];448 if (typeof value === "number") return value;449 if (typeof value === "string" && value !== "") {450 const n = Number.parseInt(value, 10);451 return Number.isFinite(n) ? n : null;452 }453 return null;454 } catch {455 return null;456 }457}458459export async function setLastSyncAt(when: number): Promise<void> {460 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });461}462463464465466467468469export interface SyncResult {470 outcome: SyncOutcome;471 detail?: string;472 473 at: number;474 entriesReceived: number;475 entriesSent: number;476}477478export async function getLastSyncResult(): Promise<SyncResult | null> {479 try {480 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);481 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];482 if (483 value &&484 typeof value === "object" &&485 typeof (value as Record<string, unknown>).outcome === "string" &&486 typeof (value as Record<string, unknown>).at === "number" &&487 typeof (value as Record<string, unknown>).entriesReceived === "number" &&488 typeof (value as Record<string, unknown>).entriesSent === "number"489 ) {490 return value as SyncResult;491 }492 return null;493 } catch {494 return null;495 }496}497498export async function setLastSyncResult(result: SyncResult): Promise<void> {499 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });500}501502function isPairedDevice(x: unknown): x is PairedDevice {503 if (typeof x !== "object" || x === null) return false;504 const r = x as Record<string, unknown>;505 if (typeof r.deviceId !== "string" || !r.deviceId) return false;506 if (typeof r.pairedAt !== "number") return false;507 if (508 r.localAlias !== undefined &&509 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)510 ) {511 return false;512 }513 if (514 r.rendezvousSecretHex !== undefined &&515 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))516 ) {517 return false;518 }519 if (r.relayUrl !== undefined) {520 if (typeof r.relayUrl !== "string") return false;521 try {522 if (new URL(r.relayUrl).protocol !== "wss:") return false;523 } catch {524 return false;525 }526 }527 return true;528}