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";2223export const REGISTRY_KEY = "peach_paired_devices_v3";24export const 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}36373839404142434445export class TrustDataCorruptedError extends Error {46 constructor(key: string, detail: string) {47 super(`trust_data_corrupted:${key}:${detail}`);48 this.name = "TrustDataCorruptedError";49 }50}5152async function listPairedDevicesRaw(): Promise<PairedDevice[]> {53 const result = await chrome.storage.local.get(REGISTRY_KEY);54 const devices = (result as Record<string, unknown>)[REGISTRY_KEY];55 if (devices === undefined) return [];56 if (!Array.isArray(devices)) {57 throw new TrustDataCorruptedError(REGISTRY_KEY, `expected array, got ${typeof devices}`);58 }59 return devices.filter(isPairedDevice);60}6162export async function listPairedDevices(): Promise<PairedDevice[]> {63 await deviceMutationTail;64 return listPairedDevicesRaw();65}6667export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {68 const devices = await listPairedDevices();69 return devices.find((d) => d.deviceId === deviceId) ?? null;70}7172export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {73 const devices = await listPairedDevices();74 return devices.find((d) =>75 d.longTermPublicKeyHex !== undefined &&76 constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex),77 ) ?? null;78}79808182838485export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {86 await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));87}8889async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {90 await chrome.storage.local.set({91 [REGISTRY_KEY]: devices.map((device) => {92 const normalized = withSigningKeyHistory(device);93 if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized;94 const scrubbed = { ...normalized };95 delete scrubbed.rendezvousSecretHex;96 return scrubbed;97 }),98 });99}100101function isValidSecret(value: unknown): value is string {102 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);103}104105106107108109export async function upsertPairedDevice(device: PairedDevice): Promise<void> {110 if (!device.deviceId) throw new Error("device.deviceId is required");111 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");112 if (!device.deviceName) throw new Error("device.deviceName is required");113 if (!device.deviceType) throw new Error("device.deviceType is required");114 await upsertPairedDevices([device]);115}116117export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {118 await enqueueDeviceMutation(async () => {119 const stored = await listPairedDevicesRaw();120 const incomingById = new Map<string, PairedDevice>();121 for (const device of devices) {122 if (!device.deviceId) throw new Error("device.deviceId is required");123 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");124 if (!device.deviceName) throw new Error("device.deviceName is required");125 if (!device.deviceType) throw new Error("device.deviceType is required");126 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };127 const previous = incomingById.get(normalized.deviceId);128 if (previous) {129 assertIdentityBinding(previous, normalized);130 throw new Error("device_identity_duplicate");131 }132 incomingById.set(normalized.deviceId, normalized);133 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);134 if (existing) assertIdentityBinding(existing, normalized);135 }136 for (const device of incomingById.values()) {137 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);138 if (index >= 0) {139 const existing = stored[index];140 const merged = { ...existing, ...device };141 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {142 merged.signingPublicKeyHex = existing.signingPublicKeyHex;143 }144 stored[index] = merged;145 } else {146 stored.push(device);147 }148 }149 await writePairedDevicesRaw(stored);150 });151}152153154export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {155 await enqueueDeviceMutation(async () => {156 const stored = await listPairedDevicesRaw();157 const incomingById = new Map<string, PairedDevice>();158 for (const device of devices) {159 const previous = incomingById.get(device.deviceId);160 if (previous) {161 assertIdentityBinding(previous, device);162 throw new Error("device_identity_duplicate");163 }164 incomingById.set(device.deviceId, device);165 const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);166 if (existing) assertIdentityBinding(existing, device);167 }168 });169}170171function validateAndIndexDevices(172 stored: PairedDevice[],173 devices: PairedDevice[],174): Map<string, PairedDevice> {175 const incomingById = new Map<string, PairedDevice>();176 for (const device of devices) {177 if (!device.deviceId) throw new Error("device.deviceId is required");178 if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");179 if (!device.deviceName) throw new Error("device.deviceName is required");180 if (!device.deviceType) throw new Error("device.deviceType is required");181 const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };182 const previous = incomingById.get(normalized.deviceId);183 if (previous) {184 assertIdentityBinding(previous, normalized);185 throw new Error("device_identity_duplicate");186 }187 incomingById.set(normalized.deviceId, normalized);188 const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);189 if (existing) assertIdentityBinding(existing, normalized);190 }191 return incomingById;192}193194function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {195 if (196 typeof existing.longTermPublicKeyHex === "string" &&197 typeof incoming.longTermPublicKeyHex === "string" &&198 !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)199 ) {200 throw new Error("device_identity_immutable");201 }202 if (203 typeof existing.signingPublicKeyHex === "string" &&204 typeof incoming.signingPublicKeyHex === "string" &&205 !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)206 ) {207 throw new Error("device_identity_immutable");208 }209}210function withSigningKeyHistory(device: PairedDevice): PairedDevice {211 if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device;212 const current = device.signingPublicKeyHex.toLowerCase();213 const history = device.signingKeyHistory214 .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))215 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));216 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 }] };217}218219export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {220 const alias = name.trim();221 if (!alias) throw new Error("device name must not be empty");222 if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");223 await enqueueDeviceMutation(async () => {224 const devices = await listPairedDevicesRaw();225 const index = devices.findIndex((device) => device.deviceId === deviceId);226 if (index < 0) throw new Error("device_not_found");227 devices[index] = { ...devices[index], localAlias: alias };228 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });229 });230}231232233234235236237238export async function setRelayUrlForAllPairedDevices(239 relayUrl: string,240): Promise<void> {241 await enqueueDeviceMutation(async () => {242 const devices = await listPairedDevicesRaw();243 if (devices.length === 0) return;244 await chrome.storage.local.set({245 [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),246 });247 });248 249 250 251}252253254255256257258259260261262263264265266267export async function removePairedDeviceById(deviceId: string): Promise<void> {268 await enqueueDeviceMutation(async () => {269 const devices = await listPairedDevicesRaw();270 await chrome.storage.local.set({271 [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),272 });273 });274 275 276}277278279280281282async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {283 const result = await chrome.storage.local.get(REVOCATIONS_KEY);284 const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];285 if (value === undefined) return [];286 if (!Array.isArray(value)) {287 throw new TrustDataCorruptedError(REVOCATIONS_KEY, `expected array, got ${typeof value}`);288 }289 return value.filter(isDeviceRevocation);290}291292export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {293 await deviceMutationTail;294 return listDeviceRevocationsRaw();295}296297298299300301302303304305export async function isDeviceRevoked(306 deviceId: string,307 longTermPublicKeyHex?: string,308): Promise<boolean> {309 310 311 const key = longTermPublicKeyHex?.toLowerCase();312 const keyIsRevocable = key !== undefined && key !== "0".repeat(64);313 await deviceMutationTail;314 return (await listDeviceRevocationsRaw()).some(315 (r) =>316 r.deviceId === deviceId ||317 (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),318 );319}320321322323324325326327328export async function revokeDeviceById(329 deviceId: string,330 revokedByDeviceId: string,331 options: { now?: number } = {},332): Promise<DeviceRevocation | null> {333 return enqueueDeviceMutation(async () => {334 const devices = await listPairedDevicesRaw();335 const device = devices.find((candidate) => candidate.deviceId === deviceId);336 const existing = await listDeviceRevocationsRaw();337 if (existing.some((r) => r.deviceId === deviceId)) {338 339 340 341 342 if (device) await chrome.storage.local.set({343 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),344 });345 return null;346 }347348 const record: DeviceRevocation = {349 deviceId,350 351 352 353 longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),354 revokedAt: options.now ?? Date.now(),355 revokedByDeviceId,356 };357 await chrome.storage.local.set({358 [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),359 [REVOCATIONS_KEY]: capRevocations([...existing, record]),360 });361 return record;362 });363}364365366367368369370export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {371 if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) {372 throw new Error("invalid paired device");373 }374 await enqueueDeviceMutation(async () => {375 const devices = await listPairedDevicesRaw();376 const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);377 const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };378 if (stored) {379 if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex &&380 !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) {381 throw new Error("device_identity_immutable");382 }383 if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex &&384 !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) {385 incoming.signingKeyHistory = [386 ...(stored.signingKeyHistory ?? []),387 { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1,388 publicKeyHex: stored.signingPublicKeyHex.toLowerCase() },389 ];390 }391 }392 const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);393 if (index >= 0) {394 const merged = { ...devices[index], ...incoming };395 if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {396 merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;397 }398 devices[index] = merged;399 } else {400 devices.push(incoming);401 }402 const revocations = await listDeviceRevocationsRaw();403 const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);404 await chrome.storage.local.set({405 [REGISTRY_KEY]: scrubRegistryRows(devices),406 [REVOCATIONS_KEY]: capRevocations(filtered),407 });408 });409}410411export async function upsertPairedDevicesAndMergeRevocations(412 devices: PairedDevice[],413 incomingRevocations: DeviceRevocation[],414): Promise<string[]> {415 return enqueueDeviceMutation(async () => {416 const stored = await listPairedDevicesRaw();417 const incomingById = validateAndIndexDevices(stored, devices);418 const existingRevocations = await listDeviceRevocationsRaw();419 const knownById = new Set(existingRevocations.map((record) => record.deviceId));420 const fresh = incomingRevocations421 .filter((record) => !knownById.has(record.deviceId))422 .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));423 const allRevocations = [...existingRevocations, ...fresh];424 const revokedIds = new Set(allRevocations.map((record) => record.deviceId));425 for (const device of incomingById.values()) {426 const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);427 if (index >= 0) {428 const existing = stored[index];429 const merged = { ...existing, ...device };430 if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {431 merged.signingPublicKeyHex = existing.signingPublicKeyHex;432 }433 stored[index] = merged;434 } else {435 stored.push(device);436 }437 }438 const revokedKeys = new Set(439 allRevocations440 .map((record) => record.longTermPublicKeyHex.toLowerCase())441 .filter((key) => key !== "0".repeat(64)),442 );443 const registry = scrubRegistryRows(444 stored.filter(445 (device) =>446 !revokedIds.has(device.deviceId) &&447 !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),448 ),449 );450 await chrome.storage.local.set({451 [REGISTRY_KEY]: registry,452 [REVOCATIONS_KEY]: capRevocations(allRevocations),453 });454 return fresh.map((record) => record.deviceId);455 });456}457458function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {459 return devices.map((entry) => {460 if (!isValidSecret(entry.rendezvousSecretHex)) return entry;461 const scrubbed = { ...entry };462 delete scrubbed.rendezvousSecretHex;463 return scrubbed;464 });465}466467468469470471472export async function hasAdoptionIdentityConflict(473 deviceId: string,474 longTermPublicKeyHex: string,475): Promise<boolean> {476 await deviceMutationTail;477 const devices = await listPairedDevicesRaw();478 if (devices.some((d) => d.deviceId === deviceId)) return true;479 const key = longTermPublicKeyHex.toLowerCase();480 return (await listDeviceRevocationsRaw()).some((record) =>481 record.deviceId === deviceId ||482 (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)483 );484}485486487488489490491492493494export async function mergeDeviceRevocations(495 incoming: DeviceRevocation[],496): Promise<string[]> {497 return enqueueDeviceMutation(async () => {498 const existing = await listDeviceRevocationsRaw();499 const known = new Set(existing.map((r) => r.deviceId));500 const fresh = incoming501 .filter((r) => !known.has(r.deviceId))502 503 504 505 .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));506 if (fresh.length === 0) return [];507 await storeRevocationsRaw([...existing, ...fresh]);508 return fresh.map((r) => r.deviceId);509 });510}511512513514515516517518519520export async function repairPairedRevocationOverlaps(): Promise<{521 revocationsCleared: number;522 pairedRowsRemoved: number;523}> {524 return enqueueDeviceMutation(async () => {525 const devices = await listPairedDevicesRaw();526 const revocations = await listDeviceRevocationsRaw();527 const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));528 const revocationIdsToClear = new Set<string>();529 const pairedIdsToRemove = new Set<string>();530531 for (const device of devices) {532 const revocation = revocationById.get(device.deviceId);533 if (!revocation) continue;534 if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {535 revocationIdsToClear.add(device.deviceId);536 } else {537 pairedIdsToRemove.add(device.deviceId);538 }539 }540541 if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {542 return { revocationsCleared: 0, pairedRowsRemoved: 0 };543 }544545 await chrome.storage.local.set({546 [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),547 [REVOCATIONS_KEY]: revocations.filter(548 (record) => !revocationIdsToClear.has(record.deviceId),549 ),550 });551 return {552 revocationsCleared: revocationIdsToClear.size,553 pairedRowsRemoved: pairedIdsToRemove.size,554 };555 });556}557558async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {559 await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });560}561562563564565566567568569570571572573574575576577function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {578 if (records.length <= MAX_REVOCATIONS) return records;579 const byIssuer = new Map<string, DeviceRevocation[]>();580 for (const record of records) {581 const key = record.revokedByDeviceId ?? "";582 const list = byIssuer.get(key);583 if (list) list.push(record);584 else byIssuer.set(key, [record]);585 }586 const dropped = new Set<DeviceRevocation>();587 for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {588 let victim: DeviceRevocation[] | undefined;589 for (const list of byIssuer.values()) {590 const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);591 if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {592 victim = list;593 }594 }595 if (!victim) break;596 const oldest = victim.find((r) => !dropped.has(r));597 if (!oldest) break;598 dropped.add(oldest);599 }600 return records.filter((r) => !dropped.has(r));601}602603function isDeviceRevocation(x: unknown): x is DeviceRevocation {604 if (typeof x !== "object" || x === null) return false;605 const r = x as Record<string, unknown>;606 return (607 typeof r.deviceId === "string" &&608 !!r.deviceId &&609 typeof r.longTermPublicKeyHex === "string" &&610 typeof r.revokedAt === "number" &&611 typeof r.revokedByDeviceId === "string"612 );613}614615export async function clearPairedDevices(): Promise<void> {616 await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));617}618619export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {620 await enqueueDeviceMutation(async () => {621 const devices = await listPairedDevicesRaw();622 const idx = devices.findIndex((d) => d.deviceId === deviceId);623 if (idx < 0) return;624 devices[idx] = { ...devices[idx], lastSyncedAt: when };625 await chrome.storage.local.set({ [REGISTRY_KEY]: devices });626 });627}628629export async function getLastSyncAt(): Promise<number | null> {630 try {631 const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);632 const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];633 if (typeof value === "number") return value;634 if (typeof value === "string" && value !== "") {635 const n = Number.parseInt(value, 10);636 return Number.isFinite(n) ? n : null;637 }638 return null;639 } catch {640 return null;641 }642}643644export async function setLastSyncAt(when: number): Promise<void> {645 await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });646}647648649650651652653654export interface SyncResult {655 outcome: SyncOutcome;656 detail?: string;657 658 at: number;659 entriesReceived: number;660 entriesSent: number;661}662663export async function getLastSyncResult(): Promise<SyncResult | null> {664 try {665 const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);666 const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];667 if (668 value &&669 typeof value === "object" &&670 typeof (value as Record<string, unknown>).outcome === "string" &&671 typeof (value as Record<string, unknown>).at === "number" &&672 typeof (value as Record<string, unknown>).entriesReceived === "number" &&673 typeof (value as Record<string, unknown>).entriesSent === "number"674 ) {675 return value as SyncResult;676 }677 return null;678 } catch {679 return null;680 }681}682683export async function setLastSyncResult(result: SyncResult): Promise<void> {684 await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });685}686687function isPairedDevice(x: unknown): x is PairedDevice {688 if (typeof x !== "object" || x === null) return false;689 const r = x as Record<string, unknown>;690 if (typeof r.deviceId !== "string" || !r.deviceId) return false;691 if (typeof r.pairedAt !== "number") return false;692 if (693 r.localAlias !== undefined &&694 (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)695 ) {696 return false;697 }698 if (699 r.rendezvousSecretHex !== undefined &&700 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))701 ) {702 return false;703 }704 if (r.relayUrl !== undefined) {705 if (typeof r.relayUrl !== "string") return false;706 try {707 if (new URL(r.relayUrl).protocol !== "wss:") return false;708 } catch {709 return false;710 }711 }712 return true;713}