123456789101112131415161718import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";19import { constantTimeEqualHex } from "../shared/constant-time";20import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";21import type { SyncOutcome } from "../shared/sync-outcome";2223const REGISTRY_KEY = "peach_paired_devices_v3";24const REVOCATIONS_KEY = "peach_device_revocations_v1";2526const MAX_REVOCATIONS = 500;27const LAST_SYNC_AT_KEY = "peach_last_sync_at";28const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";29let deviceMutationTail: Promise<void> = Promise.resolve();3031function enqueueDeviceMutation<T>(mutation: () => Promise<T>): Promise<T> {32 const result = deviceMutationTail.then(mutation, mutation);33 deviceMutationTail = result.then(() => undefined, () => undefined);34 return result;35}3637async function listPairedDevicesRaw(): Promise<PairedDevice[]> {38 try {39 const result = await chrome.storage.local.get(REGISTRY_KEY);40 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];41 if (Array.isArray(devices)) return devices.filter(isPairedDevice);42 return [];43 } catch {44 return [];45 }46}4748export async function listPairedDevices(): Promise<PairedDevice[]> {49 await deviceMutationTail;50 return listPairedDevicesRaw();51}5253export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {54 const devices = await listPairedDevices();55 return devices.find((d) => d.deviceId === deviceId) ?? null;56}5758export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {59 const devices = await listPairedDevices();60 return devices.find((d) =>61 d.longTermPublicKeyHex !== undefined &&62 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)63 ) ?? null;64}6566676869707172export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {73 await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));74}7576async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {77 await chrome.storage.local.set({78 [REGISTRY_KEY]: devices.map((device) => {79 if (!isValidSecret(device.rendezvousSecretHex)) return device;80 const scrubbed = { ...device };81 delete scrubbed.rendezvousSecretHex;82 return scrubbed;83 }),84 });85}8687function isValidSecret(value: unknown): value is string {88 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);89}909192939495export async function upsertPairedDevice(device: PairedDevice): Promise<void> {96 if (!device.deviceId) throw new Error("device.deviceId is required");97 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");98 if (!device.deviceName) throw new Error("device.deviceName is required");99 if (!device.deviceType) throw new Error("device.deviceType is required");100 await upsertPairedDevices([device]);101}102103export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {104 await enqueueDeviceMutation(async () => {105 const stored = await listPairedDevicesRaw();106 const incomingById = new Map<string, PairedDevice>();107 for (const device of devices) {108 if (!device.deviceId) throw new Error("device.deviceId is required");109 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");110 if (!device.deviceName) throw new Error("device.deviceName is required");111 if (!device.deviceType) throw new Error("device.deviceType is required");112 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };113 const previous = incomingById.get(normalized.deviceId);114 if (previous) {115 assertIdentityBinding(previous, normalized);116 throw new Error("device_identity_duplicate");117 }118 incomingById.set(normalized.deviceId, normalized);119 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);120 if (existing) assertIdentityBinding(existing, normalized);121 }122 for (const device of incomingById.values()) {123 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);124 if (index >= 0) {125 const existing = stored[index];126 const merged = { ...existing, ...device };127 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {128 merged.signingPublicKeyHex = existing.signingPublicKeyHex;129 }130 stored[index] = merged;131 } else {132 stored.push(device);133 }134 }135 await writePairedDevicesRaw(stored);136 });137}138139140export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {141 await enqueueDeviceMutation(async () => {142 const stored = await listPairedDevicesRaw();143 const incomingById = new Map<string, PairedDevice>();144 for (const device of devices) {145 const previous = incomingById.get(device.deviceId);146 if (previous) {147 assertIdentityBinding(previous, device);148 throw new Error("device_identity_duplicate");149 }150 incomingById.set(device.deviceId, device);151 const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);152 if (existing) assertIdentityBinding(existing, device);153 }154 });155}156157function validateAndIndexDevices(158 stored: PairedDevice[],159 devices: PairedDevice[],160): Map<string, PairedDevice> {161 const incomingById = new Map<string, PairedDevice>();162 for (const device of devices) {163 if (!device.deviceId) throw new Error("device.deviceId is required");164 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");165 if (!device.deviceName) throw new Error("device.deviceName is required");166 if (!device.deviceType) throw new Error("device.deviceType is required");167 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };168 const previous = incomingById.get(normalized.deviceId);169 if (previous) {170 assertIdentityBinding(previous, normalized);171 throw new Error("device_identity_duplicate");172 }173 incomingById.set(normalized.deviceId, normalized);174 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);175 if (existing) assertIdentityBinding(existing, normalized);176 }177 return incomingById;178}179180function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {181 if (182 typeof existing.longTermPublicKeyHex === "string" &&183 typeof incoming.longTermPublicKeyHex === "string" &&184 !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)185 ) {186 throw new Error("device_identity_immutable");187 }188 if (189 typeof existing.signingPublicKeyHex === "string" &&190 typeof incoming.signingPublicKeyHex === "string" &&191 !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)192 ) {193 throw new Error("device_identity_immutable");194 }195}196197export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {198 const alias = name.trim();199 if (!alias) throw new Error("device name must not be empty");200 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");201 await enqueueDeviceMutation(async () => {202 const devices = await listPairedDevicesRaw();203 const index = devices.findIndex((device) => device.deviceId === deviceId);204 if (index < 0) throw new Error("device_not_found");205 devices[index] = { ...devices[index], localAlias: alias };206 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });207 });208}209210211212213214215216export async function setRelayUrlForAllPairedDevices(217 relayUrl: string,218): Promise<void> {219 await enqueueDeviceMutation(async () => {220 const devices = await listPairedDevicesRaw();221 if (devices.length === 0) return;222 await chrome.storage.local.set({223 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),224 });225 });226 227 228 229}230231232233234235236237238239240241242243244245export async function removePairedDeviceById(deviceId: string): Promise<void> {246 await enqueueDeviceMutation(async () => {247 const devices = await listPairedDevicesRaw();248 await chrome.storage.local.set({249 [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),250 });251 });252 253 254}255256257258259260async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {261 try {262 const result = await chrome.storage.local.get(REVOCATIONS_KEY);263 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];264 if (Array.isArray(value)) return value.filter(isDeviceRevocation);265 return [];266 } catch {267 return [];268 }269}270271export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {272 await deviceMutationTail;273 return listDeviceRevocationsRaw();274}275276277278279280281282283284export async function isDeviceRevoked(285 deviceId: string,286 longTermPublicKeyHex?: string,287): Promise<boolean> {288 289 290 const key = longTermPublicKeyHex?.toLowerCase();291 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);292 await deviceMutationTail;293 return (await listDeviceRevocationsRaw()).some(294 (r) =>295 r.deviceId === deviceId ||296 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),297 );298}299300301302303304305306307export async function revokeDeviceById(308 deviceId: string,309 revokedByDeviceId: string,310 options: { now?: number } = {},311): Promise<DeviceRevocation | null> {312 return enqueueDeviceMutation(async () => {313 const devices = await listPairedDevicesRaw();314 const device = devices.find((candidate) => candidate.deviceId === deviceId);315 const existing = await listDeviceRevocationsRaw();316 if (existing.some((r) => r.deviceId === deviceId)) {317 318 319 320 321 if (device) await chrome.storage.local.set({322 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),323 });324 return null;325 }326327 const record: DeviceRevocation = {328 deviceId,329 330 331 332 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),333 revokedAt: options.now ?? Date.now(),334 revokedByDeviceId,335 };336 await chrome.storage.local.set({337 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),338 [REVOCATIONS_KEY]: capRevocations([...existing, record]),339 });340 return record;341 });342}343344345346347348349export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {350 if (!device.deviceId) throw new Error("device.deviceId is required");351 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");352 if (!device.deviceName) throw new Error("device.deviceName is required");353 if (!device.deviceType) throw new Error("device.deviceType is required");354 await enqueueDeviceMutation(async () => {355 const devices = await listPairedDevicesRaw();356 const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);357 const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };358 if (stored) assertIdentityBinding(stored, incoming);359 const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);360 if (index >= 0) {361 const merged = { ...devices[index], ...incoming };362 if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {363 merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;364 }365 devices[index] = merged;366 } else {367 devices.push(incoming);368 }369 const revocations = await listDeviceRevocationsRaw();370 const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);371 await chrome.storage.local.set({372 [REGISTRY_KEY]: scrubRegistryRows(devices),373 [REVOCATIONS_KEY]: capRevocations(filtered),374 });375 });376}377378export async function upsertPairedDevicesAndMergeRevocations(379 devices: PairedDevice[],380 incomingRevocations: DeviceRevocation[],381): Promise<string[]> {382 return enqueueDeviceMutation(async () => {383 const stored = await listPairedDevicesRaw();384 const incomingById = validateAndIndexDevices(stored, devices);385 const existingRevocations = await listDeviceRevocationsRaw();386 const knownById = new Set(existingRevocations.map((record) => record.deviceId));387 const fresh = incomingRevocations388 .filter((record) => !knownById.has(record.deviceId))389 .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));390 const allRevocations = [...existingRevocations, ...fresh];391 const revokedIds = new Set(allRevocations.map((record) => record.deviceId));392 for (const device of incomingById.values()) {393 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);394 if (index >= 0) {395 const existing = stored[index];396 const merged = { ...existing, ...device };397 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {398 merged.signingPublicKeyHex = existing.signingPublicKeyHex;399 }400 stored[index] = merged;401 } else {402 stored.push(device);403 }404 }405 const revokedKeys = new Set(406 allRevocations407 .map((record) => record.longTermPublicKeyHex.toLowerCase())408 .filter((key) => key !== "0".repeat(64)),409 );410 const registry = scrubRegistryRows(411 stored.filter(412 (device) =>413 !revokedIds.has(device.deviceId) &&414 !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),415 ),416 );417 await chrome.storage.local.set({418 [REGISTRY_KEY]: registry,419 [REVOCATIONS_KEY]: capRevocations(allRevocations),420 });421 return fresh.map((record) => record.deviceId);422 });423}424425function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {426 return devices.map((entry) => {427 if (!isValidSecret(entry.rendezvousSecretHex)) return entry;428 const scrubbed = { ...entry };429 delete scrubbed.rendezvousSecretHex;430 return scrubbed;431 });432}433434435436437438439export async function hasAdoptionIdentityConflict(440 deviceId: string,441 longTermPublicKeyHex: string,442): Promise<boolean> {443 await deviceMutationTail;444 const devices = await listPairedDevicesRaw();445 if (devices.some((d) => d.deviceId === deviceId)) return true;446 const key = longTermPublicKeyHex.toLowerCase();447 return (await listDeviceRevocationsRaw()).some((record) =>448 record.deviceId === deviceId ||449 (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)450 );451}452453454455456457458459460461export async function mergeDeviceRevocations(462 incoming: DeviceRevocation[],463): Promise<string[]> {464 return enqueueDeviceMutation(async () => {465 const existing = await listDeviceRevocationsRaw();466 const known = new Set(existing.map((r) => r.deviceId));467 const fresh = incoming468 .filter((r) => !known.has(r.deviceId))469 470 471 472 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));473 if (fresh.length === 0) return [];474 await storeRevocationsRaw([...existing, ...fresh]);475 return fresh.map((r) => r.deviceId);476 });477}478479480481482483484485486487export async function repairPairedRevocationOverlaps(): Promise<{488 revocationsCleared: number;489 pairedRowsRemoved: number;490}> {491 return enqueueDeviceMutation(async () => {492 const devices = await listPairedDevicesRaw();493 const revocations = await listDeviceRevocationsRaw();494 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));495 const revocationIdsToClear = new Set<string>();496 const pairedIdsToRemove = new Set<string>();497498 for (const device of devices) {499 const revocation = revocationById.get(device.deviceId);500 if (!revocation) continue;501 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {502 revocationIdsToClear.add(device.deviceId);503 } else {504 pairedIdsToRemove.add(device.deviceId);505 }506 }507508 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {509 return { revocationsCleared: 0, pairedRowsRemoved: 0 };510 }511512 await chrome.storage.local.set({513 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),514 [REVOCATIONS_KEY]: revocations.filter(515 (record) => !revocationIdsToClear.has(record.deviceId),516 ),517 });518 return {519 revocationsCleared: revocationIdsToClear.size,520 pairedRowsRemoved: pairedIdsToRemove.size,521 };522 });523}524525async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {526 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });527}528529530531532533534535536537538539540541542543544function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {545 if (records.length <= MAX_REVOCATIONS) return records;546 const byIssuer = new Map<string, DeviceRevocation[]>();547 for (const record of records) {548 const key = record.revokedByDeviceId ?? "";549 const list = byIssuer.get(key);550 if (list) list.push(record);551 else byIssuer.set(key, [record]);552 }553 const dropped = new Set<DeviceRevocation>();554 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {555 let victim: DeviceRevocation[] | undefined;556 for (const list of byIssuer.values()) {557 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);558 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {559 victim = list;560 }561 }562 if (!victim) break;563 const oldest = victim.find((r) => !dropped.has(r));564 if (!oldest) break;565 dropped.add(oldest);566 }567 return records.filter((r) => !dropped.has(r));568}569570function isDeviceRevocation(x: unknown): x is DeviceRevocation {571 if (typeof x !== "object" || x === null) return false;572 const r = x as Record<string, unknown>;573 return (574 typeof r.deviceId === "string" &&575 !!r.deviceId &&576 typeof r.longTermPublicKeyHex === "string" &&577 typeof r.revokedAt === "number" &&578 typeof r.revokedByDeviceId === "string"579 );580}581582export async function clearPairedDevices(): Promise<void> {583 await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));584}585586export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {587 await enqueueDeviceMutation(async () => {588 const devices = await listPairedDevicesRaw();589 const idx = devices.findIndex((d) => d.deviceId === deviceId);590 if (idx < 0) return;591 devices[idx] = { ...devices[idx], lastSyncedAt: when };592 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });593 });594}595596export async function getLastSyncAt(): Promise<number | null> {597 try {598 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);599 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];600 if (typeof value === "number") return value;601 if (typeof value === "string" && value !== "") {602 const n = Number.parseInt(value, 10);603 return Number.isFinite(n) ? n : null;604 }605 return null;606 } catch {607 return null;608 }609}610611export async function setLastSyncAt(when: number): Promise<void> {612 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });613}614615616617618619620621export interface SyncResult {622 outcome: SyncOutcome;623 detail?: string;624 625 at: number;626 entriesReceived: number;627 entriesSent: number;628}629630export async function getLastSyncResult(): Promise<SyncResult | null> {631 try {632 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);633 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];634 if (635 value &&636 typeof value === "object" &&637 typeof (value as Record<string, unknown>).outcome === "string" &&638 typeof (value as Record<string, unknown>).at === "number" &&639 typeof (value as Record<string, unknown>).entriesReceived === "number" &&640 typeof (value as Record<string, unknown>).entriesSent === "number"641 ) {642 return value as SyncResult;643 }644 return null;645 } catch {646 return null;647 }648}649650export async function setLastSyncResult(result: SyncResult): Promise<void> {651 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });652}653654function isPairedDevice(x: unknown): x is PairedDevice {655 if (typeof x !== "object" || x === null) return false;656 const r = x as Record<string, unknown>;657 if (typeof r.deviceId !== "string" || !r.deviceId) return false;658 if (typeof r.pairedAt !== "number") return false;659 if (660 r.localAlias !== undefined &&661 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)662 ) {663 return false;664 }665 if (666 r.rendezvousSecretHex !== undefined &&667 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))668 ) {669 return false;670 }671 if (r.relayUrl !== undefined) {672 if (typeof r.relayUrl !== "string") return false;673 try {674 if (new URL(r.relayUrl).protocol !== "wss:") return false;675 } catch {676 return false;677 }678 }679 return true;680}