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 const normalized = withSigningKeyHistory(device);80 if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized;81 const scrubbed = { ...normalized };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}919293949596export async function upsertPairedDevice(device: PairedDevice): Promise<void> {97 if (!device.deviceId) throw new Error("device.deviceId is required");98 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");99 if (!device.deviceName) throw new Error("device.deviceName is required");100 if (!device.deviceType) throw new Error("device.deviceType is required");101 await upsertPairedDevices([device]);102}103104export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {105 await enqueueDeviceMutation(async () => {106 const stored = await listPairedDevicesRaw();107 const incomingById = new Map<string, PairedDevice>();108 for (const device of devices) {109 if (!device.deviceId) throw new Error("device.deviceId is required");110 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");111 if (!device.deviceName) throw new Error("device.deviceName is required");112 if (!device.deviceType) throw new Error("device.deviceType is required");113 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };114 const previous = incomingById.get(normalized.deviceId);115 if (previous) {116 assertIdentityBinding(previous, normalized);117 throw new Error("device_identity_duplicate");118 }119 incomingById.set(normalized.deviceId, normalized);120 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);121 if (existing) assertIdentityBinding(existing, normalized);122 }123 for (const device of incomingById.values()) {124 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);125 if (index >= 0) {126 const existing = stored[index];127 const merged = { ...existing, ...device };128 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {129 merged.signingPublicKeyHex = existing.signingPublicKeyHex;130 }131 stored[index] = merged;132 } else {133 stored.push(device);134 }135 }136 await writePairedDevicesRaw(stored);137 });138}139140141export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {142 await enqueueDeviceMutation(async () => {143 const stored = await listPairedDevicesRaw();144 const incomingById = new Map<string, PairedDevice>();145 for (const device of devices) {146 const previous = incomingById.get(device.deviceId);147 if (previous) {148 assertIdentityBinding(previous, device);149 throw new Error("device_identity_duplicate");150 }151 incomingById.set(device.deviceId, device);152 const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);153 if (existing) assertIdentityBinding(existing, device);154 }155 });156}157158function validateAndIndexDevices(159 stored: PairedDevice[],160 devices: PairedDevice[],161): Map<string, PairedDevice> {162 const incomingById = new Map<string, PairedDevice>();163 for (const device of devices) {164 if (!device.deviceId) throw new Error("device.deviceId is required");165 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");166 if (!device.deviceName) throw new Error("device.deviceName is required");167 if (!device.deviceType) throw new Error("device.deviceType is required");168 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };169 const previous = incomingById.get(normalized.deviceId);170 if (previous) {171 assertIdentityBinding(previous, normalized);172 throw new Error("device_identity_duplicate");173 }174 incomingById.set(normalized.deviceId, normalized);175 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);176 if (existing) assertIdentityBinding(existing, normalized);177 }178 return incomingById;179}180181function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {182 if (183 typeof existing.longTermPublicKeyHex === "string" &&184 typeof incoming.longTermPublicKeyHex === "string" &&185 !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)186 ) {187 throw new Error("device_identity_immutable");188 }189 if (190 typeof existing.signingPublicKeyHex === "string" &&191 typeof incoming.signingPublicKeyHex === "string" &&192 !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)193 ) {194 throw new Error("device_identity_immutable");195 }196}197function withSigningKeyHistory(device: PairedDevice): PairedDevice {198 if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device;199 const current = device.signingPublicKeyHex.toLowerCase();200 const history = device.signingKeyHistory201 .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))202 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));203 return { ...device, signingKeyHistory: [...history.filter((key) => key.publicKeyHex !== current), { version: history.find((key) => key.publicKeyHex === current)?.version ?? history.reduce((max, key) => Math.max(max, key.version), 0) + 1, publicKeyHex: current }] };204}205206export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {207 const alias = name.trim();208 if (!alias) throw new Error("device name must not be empty");209 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");210 await enqueueDeviceMutation(async () => {211 const devices = await listPairedDevicesRaw();212 const index = devices.findIndex((device) => device.deviceId === deviceId);213 if (index < 0) throw new Error("device_not_found");214 devices[index] = { ...devices[index], localAlias: alias };215 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });216 });217}218219220221222223224225export async function setRelayUrlForAllPairedDevices(226 relayUrl: string,227): Promise<void> {228 await enqueueDeviceMutation(async () => {229 const devices = await listPairedDevicesRaw();230 if (devices.length === 0) return;231 await chrome.storage.local.set({232 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),233 });234 });235 236 237 238}239240241242243244245246247248249250251252253254export async function removePairedDeviceById(deviceId: string): Promise<void> {255 await enqueueDeviceMutation(async () => {256 const devices = await listPairedDevicesRaw();257 await chrome.storage.local.set({258 [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),259 });260 });261 262 263}264265266267268269async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {270 try {271 const result = await chrome.storage.local.get(REVOCATIONS_KEY);272 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];273 if (Array.isArray(value)) return value.filter(isDeviceRevocation);274 return [];275 } catch {276 return [];277 }278}279280export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {281 await deviceMutationTail;282 return listDeviceRevocationsRaw();283}284285286287288289290291292293export async function isDeviceRevoked(294 deviceId: string,295 longTermPublicKeyHex?: string,296): Promise<boolean> {297 298 299 const key = longTermPublicKeyHex?.toLowerCase();300 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);301 await deviceMutationTail;302 return (await listDeviceRevocationsRaw()).some(303 (r) =>304 r.deviceId === deviceId ||305 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),306 );307}308309310311312313314315316export async function revokeDeviceById(317 deviceId: string,318 revokedByDeviceId: string,319 options: { now?: number } = {},320): Promise<DeviceRevocation | null> {321 return enqueueDeviceMutation(async () => {322 const devices = await listPairedDevicesRaw();323 const device = devices.find((candidate) => candidate.deviceId === deviceId);324 const existing = await listDeviceRevocationsRaw();325 if (existing.some((r) => r.deviceId === deviceId)) {326 327 328 329 330 if (device) await chrome.storage.local.set({331 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),332 });333 return null;334 }335336 const record: DeviceRevocation = {337 deviceId,338 339 340 341 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),342 revokedAt: options.now ?? Date.now(),343 revokedByDeviceId,344 };345 await chrome.storage.local.set({346 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),347 [REVOCATIONS_KEY]: capRevocations([...existing, record]),348 });349 return record;350 });351}352353354355356357358export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {359 if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) {360 throw new Error("invalid paired device");361 }362 await enqueueDeviceMutation(async () => {363 const devices = await listPairedDevicesRaw();364 const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);365 const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };366 if (stored) {367 if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex &&368 !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) {369 throw new Error("device_identity_immutable");370 }371 if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex &&372 !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) {373 incoming.signingKeyHistory = [374 ...(stored.signingKeyHistory ?? []),375 { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1,376 publicKeyHex: stored.signingPublicKeyHex.toLowerCase() },377 ];378 }379 }380 const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);381 if (index >= 0) {382 const merged = { ...devices[index], ...incoming };383 if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {384 merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;385 }386 devices[index] = merged;387 } else {388 devices.push(incoming);389 }390 const revocations = await listDeviceRevocationsRaw();391 const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);392 await chrome.storage.local.set({393 [REGISTRY_KEY]: scrubRegistryRows(devices),394 [REVOCATIONS_KEY]: capRevocations(filtered),395 });396 });397}398399export async function upsertPairedDevicesAndMergeRevocations(400 devices: PairedDevice[],401 incomingRevocations: DeviceRevocation[],402): Promise<string[]> {403 return enqueueDeviceMutation(async () => {404 const stored = await listPairedDevicesRaw();405 const incomingById = validateAndIndexDevices(stored, devices);406 const existingRevocations = await listDeviceRevocationsRaw();407 const knownById = new Set(existingRevocations.map((record) => record.deviceId));408 const fresh = incomingRevocations409 .filter((record) => !knownById.has(record.deviceId))410 .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));411 const allRevocations = [...existingRevocations, ...fresh];412 const revokedIds = new Set(allRevocations.map((record) => record.deviceId));413 for (const device of incomingById.values()) {414 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);415 if (index >= 0) {416 const existing = stored[index];417 const merged = { ...existing, ...device };418 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {419 merged.signingPublicKeyHex = existing.signingPublicKeyHex;420 }421 stored[index] = merged;422 } else {423 stored.push(device);424 }425 }426 const revokedKeys = new Set(427 allRevocations428 .map((record) => record.longTermPublicKeyHex.toLowerCase())429 .filter((key) => key !== "0".repeat(64)),430 );431 const registry = scrubRegistryRows(432 stored.filter(433 (device) =>434 !revokedIds.has(device.deviceId) &&435 !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),436 ),437 );438 await chrome.storage.local.set({439 [REGISTRY_KEY]: registry,440 [REVOCATIONS_KEY]: capRevocations(allRevocations),441 });442 return fresh.map((record) => record.deviceId);443 });444}445446function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {447 return devices.map((entry) => {448 if (!isValidSecret(entry.rendezvousSecretHex)) return entry;449 const scrubbed = { ...entry };450 delete scrubbed.rendezvousSecretHex;451 return scrubbed;452 });453}454455456457458459460export async function hasAdoptionIdentityConflict(461 deviceId: string,462 longTermPublicKeyHex: string,463): Promise<boolean> {464 await deviceMutationTail;465 const devices = await listPairedDevicesRaw();466 if (devices.some((d) => d.deviceId === deviceId)) return true;467 const key = longTermPublicKeyHex.toLowerCase();468 return (await listDeviceRevocationsRaw()).some((record) =>469 record.deviceId === deviceId ||470 (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)471 );472}473474475476477478479480481482export async function mergeDeviceRevocations(483 incoming: DeviceRevocation[],484): Promise<string[]> {485 return enqueueDeviceMutation(async () => {486 const existing = await listDeviceRevocationsRaw();487 const known = new Set(existing.map((r) => r.deviceId));488 const fresh = incoming489 .filter((r) => !known.has(r.deviceId))490 491 492 493 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));494 if (fresh.length === 0) return [];495 await storeRevocationsRaw([...existing, ...fresh]);496 return fresh.map((r) => r.deviceId);497 });498}499500501502503504505506507508export async function repairPairedRevocationOverlaps(): Promise<{509 revocationsCleared: number;510 pairedRowsRemoved: number;511}> {512 return enqueueDeviceMutation(async () => {513 const devices = await listPairedDevicesRaw();514 const revocations = await listDeviceRevocationsRaw();515 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));516 const revocationIdsToClear = new Set<string>();517 const pairedIdsToRemove = new Set<string>();518519 for (const device of devices) {520 const revocation = revocationById.get(device.deviceId);521 if (!revocation) continue;522 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {523 revocationIdsToClear.add(device.deviceId);524 } else {525 pairedIdsToRemove.add(device.deviceId);526 }527 }528529 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {530 return { revocationsCleared: 0, pairedRowsRemoved: 0 };531 }532533 await chrome.storage.local.set({534 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),535 [REVOCATIONS_KEY]: revocations.filter(536 (record) => !revocationIdsToClear.has(record.deviceId),537 ),538 });539 return {540 revocationsCleared: revocationIdsToClear.size,541 pairedRowsRemoved: pairedIdsToRemove.size,542 };543 });544}545546async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {547 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });548}549550551552553554555556557558559560561562563564565function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {566 if (records.length <= MAX_REVOCATIONS) return records;567 const byIssuer = new Map<string, DeviceRevocation[]>();568 for (const record of records) {569 const key = record.revokedByDeviceId ?? "";570 const list = byIssuer.get(key);571 if (list) list.push(record);572 else byIssuer.set(key, [record]);573 }574 const dropped = new Set<DeviceRevocation>();575 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {576 let victim: DeviceRevocation[] | undefined;577 for (const list of byIssuer.values()) {578 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);579 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {580 victim = list;581 }582 }583 if (!victim) break;584 const oldest = victim.find((r) => !dropped.has(r));585 if (!oldest) break;586 dropped.add(oldest);587 }588 return records.filter((r) => !dropped.has(r));589}590591function isDeviceRevocation(x: unknown): x is DeviceRevocation {592 if (typeof x !== "object" || x === null) return false;593 const r = x as Record<string, unknown>;594 return (595 typeof r.deviceId === "string" &&596 !!r.deviceId &&597 typeof r.longTermPublicKeyHex === "string" &&598 typeof r.revokedAt === "number" &&599 typeof r.revokedByDeviceId === "string"600 );601}602603export async function clearPairedDevices(): Promise<void> {604 await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));605}606607export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {608 await enqueueDeviceMutation(async () => {609 const devices = await listPairedDevicesRaw();610 const idx = devices.findIndex((d) => d.deviceId === deviceId);611 if (idx < 0) return;612 devices[idx] = { ...devices[idx], lastSyncedAt: when };613 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });614 });615}616617export async function getLastSyncAt(): Promise<number | null> {618 try {619 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);620 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];621 if (typeof value === "number") return value;622 if (typeof value === "string" && value !== "") {623 const n = Number.parseInt(value, 10);624 return Number.isFinite(n) ? n : null;625 }626 return null;627 } catch {628 return null;629 }630}631632export async function setLastSyncAt(when: number): Promise<void> {633 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });634}635636637638639640641642export interface SyncResult {643 outcome: SyncOutcome;644 detail?: string;645 646 at: number;647 entriesReceived: number;648 entriesSent: number;649}650651export async function getLastSyncResult(): Promise<SyncResult | null> {652 try {653 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);654 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];655 if (656 value &&657 typeof value === "object" &&658 typeof (value as Record<string, unknown>).outcome === "string" &&659 typeof (value as Record<string, unknown>).at === "number" &&660 typeof (value as Record<string, unknown>).entriesReceived === "number" &&661 typeof (value as Record<string, unknown>).entriesSent === "number"662 ) {663 return value as SyncResult;664 }665 return null;666 } catch {667 return null;668 }669}670671export async function setLastSyncResult(result: SyncResult): Promise<void> {672 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });673}674675function isPairedDevice(x: unknown): x is PairedDevice {676 if (typeof x !== "object" || x === null) return false;677 const r = x as Record<string, unknown>;678 if (typeof r.deviceId !== "string" || !r.deviceId) return false;679 if (typeof r.pairedAt !== "number") return false;680 if (681 r.localAlias !== undefined &&682 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)683 ) {684 return false;685 }686 if (687 r.rendezvousSecretHex !== undefined &&688 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))689 ) {690 return false;691 }692 if (r.relayUrl !== undefined) {693 if (typeof r.relayUrl !== "string") return false;694 try {695 if (new URL(r.relayUrl).protocol !== "wss:") return false;696 } catch {697 return false;698 }699 }700 return true;701}