1import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";2import { capDeviceName, SYNC_LIMITS } from "./sync-limits";3import { computeVaultId } from "./sync-identity";4import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";5import type { LocalIdentity, PairingTransport } from "./pairing-handshake";67export interface AdoptRequest {8 type: "ADOPT_REQUEST";9 device_id: string;10 device_name: string;11 device_type: DeviceType;12 13 license?: string;14 1516171819 snapshot_capable?: boolean;20}2122export interface AdoptGrant {23 type: "ADOPT_GRANT";24 root_key_hex: string;25 vault_id: string;26 license?: string;27 source: {28 device_id: string;29 device_name: string;30 device_type: DeviceType;31 long_term_public_key_hex: string;32 signing_public_key_hex?: string;33 ws_port?: number;34 relay_url?: string;35 };36 3738394041 snapshot_capable?: boolean;42}4344export interface AdoptConfirm {45 type: "ADOPT_CONFIRM";46 confirmed: boolean;47 48495051 license?: string;52}5354export interface AdoptIdentity {55 type: "ADOPT_IDENTITY";56 device_id: string;57 device_name: string;58 device_type: DeviceType;59 long_term_public_key_hex: string;60 signing_public_key_hex?: string;61}62636465666768export interface AdoptComplete {69 type: "ADOPT_COMPLETE";70}717273export interface AdoptApplied {74 type: "ADOPT_APPLIED";75}767778export interface AdoptReject {79 type: "ADOPT_REJECT";80 reason: "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";81}82838485868788export interface AdoptSnapshot {89 type: "ADOPT_SNAPSHOT";90 chunk_index: number;91 chunk_count: number;92 entries: Record<string, string>;93 94 tombstones: Record<string, number>;95 96979899100 duress_slot?: string;101}102103104export interface AdoptSnapshotApplied {105 type: "ADOPT_SNAPSHOT_APPLIED";106 entries_received: number;107 tombstones_received: number;108}109110111export interface AdoptDeviceManifestFrame {112 type: "ADOPT_DEVICE_MANIFEST";113 manifest: SignedDeviceManifest;114}115116117export interface AdoptDeviceManifestApplied {118 type: "ADOPT_DEVICE_MANIFEST_APPLIED";119}120121export type AdoptionFrame =122 | AdoptRequest123 | AdoptConfirm124 | AdoptGrant125 | AdoptIdentity126 | AdoptComplete127 | AdoptApplied128 | AdoptReject129 | AdoptSnapshot130 | AdoptSnapshotApplied131 | AdoptDeviceManifestFrame132 | AdoptDeviceManifestApplied;133134135136137138139140export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 141export const MAX_SNAPSHOT_CHUNKS = 200;142143144export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;145146147148149150export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(151 SNAPSHOT_FRAME_WIRE_CEILING + 4,152);153154155156157158159160161162163export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; 164export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;165export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;166167export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;168169170171172export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =173 (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;174175176177178179export interface AdoptSnapshotBudget {180 entries: Record<string, string>;181 tombstones: Record<string, number>;182 duressSlot: string | null;183 184 bytes: number;185 entryCount: number;186 tombstoneCount: number;187}188189export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {190 return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };191}192193194195196197198199200201202203export function applyAdoptSnapshotFrame(204 acc: AdoptSnapshotBudget,205 entries: Record<string, string>,206 tombstones: Record<string, number>,207 duressSlot: string | null | undefined,208 frameBytes: number,209): void {210 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {211 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");212 }213 for (const [id, envelope] of Object.entries(entries)) {214 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");215 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {216 throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");217 }218 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {219 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");220 }221 acc.entryCount++;222 acc.entries[id] = envelope;223 }224 for (const [id, ts] of Object.entries(tombstones)) {225 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");226 if (typeof ts !== "number" || !Number.isFinite(ts)) {227 throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");228 }229 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {230 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");231 }232 acc.tombstoneCount++;233 acc.tombstones[id] = ts;234 }235 if (typeof duressSlot === "string" && duressSlot.length > 0) {236 237 238 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {239 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");240 }241 acc.duressSlot = duressSlot;242 }243 acc.bytes += frameBytes;244}245246export interface AdoptionPairState {247 rendezvousSecretHex: string;248 relayUrl?: string;249}250251export interface AdoptionReceiverDeps {252 deviceName: string;253 generateDeviceId(): string;254 255 confirmSas(): Promise<boolean>;256 257258259260 validateSource?(source: AdoptGrant["source"]): Promise<void>;261 262263264265266 hasSourceIdentityCollision?(source: AdoptGrant["source"]): Promise<boolean>;267 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;268 deriveLocalIdentity(): Promise<LocalIdentity>;269 rotateLocalIdentity?(): Promise<LocalIdentity>;270 upsertPairedDevice(device: PairedDevice): Promise<void>;271 applyLicense?(license: string): Promise<void>;272 273 license?(): Promise<string | undefined>;274 275 pairState?: AdoptionPairState;276 onError(error: string): void;277}278279export interface AdoptionSourceDeps {280 sourceIdentity: LocalIdentity;281 rootKeyHex(): Promise<string>;282 vaultId(): Promise<string>;283 license?(): Promise<string | undefined>;284 285 applyLicense?(license: string): Promise<void>;286 onConsent(request: AdoptRequest): Promise<boolean>;287 288 upsertPairedDevice(device: PairedDevice): Promise<void>;289 290 persistPendingHolder?(request: AdoptRequest): Promise<void>;291 292 clearPendingHolder?(request: AdoptRequest, device: PairedDevice): Promise<void>293 294295296297298 hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;299 300301302303304 rotateSourceIdentity?(): Promise<LocalIdentity>;305 306 pairState?: AdoptionPairState;307}308309export function isAdoptRequest(frame: unknown): frame is AdoptRequest {310 const value = frame as Partial<AdoptRequest> | null;311 return value?.type === "ADOPT_REQUEST" &&312 typeof value.device_id === "string" &&313 value.device_id.length > 0 &&314 typeof value.device_name === "string" &&315 isDeviceType(value.device_type) &&316 (value.license === undefined || typeof value.license === "string");317}318319export function isAdoptGrant(frame: unknown): frame is AdoptGrant {320 const value = frame as Partial<AdoptGrant> | null;321 return value?.type === "ADOPT_GRANT" &&322 validateRootKeyHex(value.root_key_hex) &&323 typeof value.vault_id === "string" &&324 /^[0-9a-f]{64}$/.test(value.vault_id) &&325 typeof value.source === "object" &&326 value.source !== null &&327 typeof value.source.device_id === "string" &&328 typeof value.source.device_name === "string" &&329 isDeviceType(value.source.device_type) &&330 typeof value.source.long_term_public_key_hex === "string" &&331 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&332 (value.source.relay_url === undefined ||333 (typeof value.source.relay_url === "string" &&334 isSecureRelayUrl(value.source.relay_url))) &&335 (value.snapshot_capable === undefined ||336 typeof value.snapshot_capable === "boolean");337}338339export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {340 const value = frame as Partial<AdoptConfirm> | null;341 return value?.type === "ADOPT_CONFIRM" &&342 typeof value.confirmed === "boolean";343}344345export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {346 const value = frame as Partial<AdoptIdentity> | null;347 return value?.type === "ADOPT_IDENTITY" &&348 typeof value.device_id === "string" &&349 value.device_id.length > 0 &&350 typeof value.device_name === "string" &&351 isDeviceType(value.device_type) &&352 typeof value.long_term_public_key_hex === "string" &&353 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);354}355356export function isAdoptComplete(frame: unknown): frame is AdoptComplete {357 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";358}359360export function isAdoptApplied(frame: unknown): frame is AdoptApplied {361 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";362}363364export function isAdoptReject(frame: unknown): frame is AdoptReject {365 const value = frame as Partial<AdoptReject> | null;366 return value?.type === "ADOPT_REJECT" &&367 (value.reason === "ADOPT_PRO_REQUIRED" ||368 value.reason === "ADOPT_DEVICE_ID_COLLISION");369}370371export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {372 const value = frame as Partial<AdoptSnapshot> | null;373 if (value?.type !== "ADOPT_SNAPSHOT") return false;374 if (375 typeof value.chunk_index !== "number" ||376 !Number.isInteger(value.chunk_index) ||377 value.chunk_index < 0378 ) return false;379 if (380 typeof value.chunk_count !== "number" ||381 !Number.isInteger(value.chunk_count) ||382 value.chunk_count < 1383 ) return false;384 if (!isStringRecord(value.entries)) return false;385 if (!isNumberRecord(value.tombstones)) return false;386 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;387 return true;388}389390export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {391 const value = frame as Partial<AdoptSnapshotApplied> | null;392 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&393 typeof value.entries_received === "number" &&394 typeof value.tombstones_received === "number";395}396397export function isAdoptDeviceManifestFrame(398 frame: unknown,399): frame is AdoptDeviceManifestFrame {400 const value = frame as Partial<AdoptDeviceManifestFrame> | null;401 const manifest = value?.manifest as402 | { body?: unknown; signatureHex?: unknown }403 | null;404 return value?.type === "ADOPT_DEVICE_MANIFEST" &&405 typeof manifest === "object" &&406 manifest !== null &&407 typeof manifest.body === "object" &&408 manifest.body !== null &&409 typeof manifest.signatureHex === "string";410}411412function isStringRecord(value: unknown): value is Record<string, string> {413 if (typeof value !== "object" || value === null) return false;414 return Object.values(value).every((v) => typeof v === "string");415}416417function isNumberRecord(value: unknown): value is Record<string, number> {418 if (typeof value !== "object" || value === null) return false;419 return Object.values(value).every((v) => typeof v === "number");420}421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456export async function sendVaultSnapshot(457 channel: PairingTransport,458 entries: Record<string, string>,459 tombstones: Record<string, number>,460 duressSlot: string | null = null,461): Promise<void> {462 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);463 464 465 466 467 468 const acc = createAdoptSnapshotBudget();469 const encoded: Uint8Array[] = frames.map((frame) =>470 new TextEncoder().encode(JSON.stringify(frame))471 );472 for (let index = 0; index < frames.length; index++) {473 const frame = frames[index];474 applyAdoptSnapshotFrame(475 acc,476 frame.entries,477 frame.tombstones,478 frame.duress_slot,479 encoded[index].length,480 );481 }482 483 484 485 for (const raw of encoded) await channel.send(padSyncFrame(raw));486}487488489490491492493function buildAdoptSnapshotFrames(494 entries: Record<string, string>,495 tombstones: Record<string, number>,496 duressSlot: string | null,497): AdoptSnapshot[] {498 const includeDuress = !!duressSlot;499 500 501 502 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;503504 const chunks: Array<Record<string, string>> = [];505 let current: Record<string, string> = {};506 let currentCount = 0;507 let currentBytes = 2; 508 for (const [id, envelope] of Object.entries(entries)) {509 510 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;511 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {512 chunks.push(current);513 current = {};514 currentCount = 0;515 currentBytes = 2;516 }517 current[id] = envelope;518 currentCount++;519 currentBytes += entryBytes;520 }521 chunks.push(current);522523 const entriesChunkCount = Math.min(chunks.length, entryCap);524 const sendDuress = includeDuress;525 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);526 const duressFrame: AdoptSnapshot | null = sendDuress527 ? {528 type: "ADOPT_SNAPSHOT",529 chunk_index: entriesChunkCount,530 chunk_count: totalChunks,531 entries: {},532 tombstones,533 duress_slot: duressSlot as string,534 }535 : null;536537 if (538 duressFrame &&539 new TextEncoder().encode(JSON.stringify(duressFrame)).length >540 SNAPSHOT_FRAME_WIRE_CEILING541 ) {542 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");543 }544545 const frames: AdoptSnapshot[] = [];546 for (let index = 0; index < entriesChunkCount; index++) {547 const isFinalEntries = index === entriesChunkCount - 1;548 frames.push({549 type: "ADOPT_SNAPSHOT",550 chunk_index: index,551 chunk_count: totalChunks,552 entries: chunks[index],553 554 555 tombstones: isFinalEntries && !sendDuress ? tombstones : {},556 });557 }558559 if (duressFrame) frames.push(duressFrame);560 return frames;561}562563564565566567568569570571572573574export async function receiveVaultSnapshot(575 channel: PairingTransport,576): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {577 const acc = createAdoptSnapshotBudget();578 let chunkCount = -1;579 let received = 0;580 while (chunkCount === -1 || received < chunkCount) {581 const wire = await channel.receive();582 if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {583 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");584 }585 586 587 const raw = unpadSyncFrame(wire);588 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {589 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");590 }591 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {592 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");593 }594 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));595 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");596 if (chunkCount === -1) {597 chunkCount = frame.chunk_count;598 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {599 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");600 }601 } else if (frame.chunk_count !== chunkCount) {602 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");603 }604 if (frame.chunk_index !== received) {605 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");606 }607 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);608 received++;609 }610 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };611}612613export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {614 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);615}616617export interface AdoptionRequestResult {618 device: PairedDevice;619 620 snapshotCapable: boolean;621}622623export async function requestAdoption(624 channel: PairingTransport,625 deps: AdoptionReceiverDeps,626): Promise<AdoptionRequestResult | null> {627 const receiverLicense = await deps.license?.();628 await sendFrame(channel, {629 type: "ADOPT_REQUEST",630 device_id: deps.generateDeviceId(),631 device_name: deps.deviceName,632 device_type: "extension",633 snapshot_capable: true,634 });635636 const confirmed = await deps.confirmSas();637 638 await sendFrame(channel, {639 type: "ADOPT_CONFIRM",640 confirmed,641 license: receiverLicense,642 });643 if (!confirmed) {644 deps.onError("ADOPT_SAS_REJECTED");645 return null;646 }647648 const frame = await receiveFrame(channel);649 if (isAdoptReject(frame)) {650 deps.onError(frame.reason);651 return null;652 }653 if (!isAdoptGrant(frame)) {654 deps.onError("ADOPT_UNEXPECTED_FRAME");655 return null;656 }657 const grantSnapshotCapable = frame.snapshot_capable === true;658659 const rootKeyBytes = hexToBytes(frame.root_key_hex);660 try {661 const computedVaultId = await computeVaultId(rootKeyBytes);662 if (computedVaultId !== frame.vault_id) {663 deps.onError("ADOPT_VAULT_ID_MISMATCH");664 return null;665 }666 } finally {667 rootKeyBytes.fill(0);668 }669 if (!receiverLicense && !frame.license) {670 deps.onError("ADOPT_PRO_REQUIRED");671 return null;672 }673674 await deps.validateSource?.(frame.source);675676 677 678 679 680 681 let grant = frame;682 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {683 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });684 const retry = await receiveFrame(channel);685 if (!isAdoptGrant(retry)) {686 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");687 return null;688 }689 grant = retry;690 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {691 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });692 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");693 return null;694 }695 696 const freshRootBytes = hexToBytes(grant.root_key_hex);697 try {698 if (await computeVaultId(freshRootBytes) !== grant.vault_id) {699 deps.onError("ADOPT_VAULT_ID_MISMATCH");700 return null;701 }702 } finally {703 freshRootBytes.fill(0);704 }705 }706707 const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);708 if (vaultId !== grant.vault_id) {709 deps.onError("ADOPT_VAULT_ID_MISMATCH");710 return null;711 }712713 if (!receiverLicense && grant.license) {714 await deps.applyLicense?.(grant.license);715 }716717 let identity = await deps.deriveLocalIdentity();718 await sendFrame(channel, {719 type: "ADOPT_IDENTITY",720 device_id: identity.deviceId,721 device_name: identity.deviceName,722 device_type: identity.deviceType,723 long_term_public_key_hex: identity.longTermPublicKeyHex,724 signing_public_key_hex: identity.signingPublicKeyHex,725 });726727 let completion = await receiveFrame(channel);728 if (729 isAdoptReject(completion) &&730 completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&731 deps.rotateLocalIdentity732 ) {733 identity = await deps.rotateLocalIdentity();734 await sendFrame(channel, {735 type: "ADOPT_IDENTITY",736 device_id: identity.deviceId,737 device_name: identity.deviceName,738 device_type: identity.deviceType,739 long_term_public_key_hex: identity.longTermPublicKeyHex,740 signing_public_key_hex: identity.signingPublicKeyHex,741 });742 completion = await receiveFrame(channel);743 }744 if (!isAdoptComplete(completion)) {745 deps.onError("ADOPT_COMPLETION_MISSING");746 return null;747 }748749 const sourceDevice: PairedDevice = {750 deviceId: grant.source.device_id,751 deviceName: capDeviceName(grant.source.device_name),752 deviceType: grant.source.device_type,753 longTermPublicKeyHex: grant.source.long_term_public_key_hex,754 signingPublicKeyHex: grant.source.signing_public_key_hex,755 trustSource: "direct",756 wsPort: grant.source.ws_port,757 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,758 relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,759 pairedAt: Date.now(),760 };761 await deps.upsertPairedDevice(sourceDevice);762 await sendFrame(channel, { type: "ADOPT_APPLIED" });763 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };764}765766export async function grantAdoption(767 channel: PairingTransport,768 request: AdoptRequest,769 deps: AdoptionSourceDeps,770): Promise<PairedDevice | null> {771 const approved = await deps.onConsent(request);772 if (!approved) return null;773 const confirmation = await receiveFrame(channel);774 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;775 await deps.persistPendingHolder?.(request);776777 let sourceLicense = await deps.license?.();778 779 780 const receiverLicense = confirmation.license ?? request.license;781 if (!sourceLicense && receiverLicense) {782 try {783 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");784 await deps.applyLicense(receiverLicense);785 sourceLicense = await deps.license?.();786 } catch {787 sourceLicense = undefined;788 }789 }790 if (!sourceLicense) {791 await sendFrame(channel, {792 type: "ADOPT_REJECT",793 reason: "ADOPT_PRO_REQUIRED",794 });795 throw new Error("ADOPT_PRO_REQUIRED");796 }797798 const rootKeyHex = await deps.rootKeyHex();799 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");800801 let sourceIdentity = deps.sourceIdentity;802 let grantAttempts = 0;803 while (true) {804 await sendFrame(channel, {805 type: "ADOPT_GRANT",806 root_key_hex: rootKeyHex,807 vault_id: await deps.vaultId(),808 license: sourceLicense,809 source: {810 device_id: sourceIdentity.deviceId,811 device_name: sourceIdentity.deviceName,812 device_type: sourceIdentity.deviceType,813 long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,814 signing_public_key_hex: sourceIdentity.signingPublicKeyHex,815 ws_port: sourceIdentity.wsPort,816 relay_url: deps.pairState?.relayUrl,817 },818 snapshot_capable: request.snapshot_capable === true,819 });820821 const identity = await receiveFrame(channel);822823 824 825 if (826 isAdoptReject(identity) &&827 identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&828 deps.rotateSourceIdentity &&829 grantAttempts === 0830 ) {831 sourceIdentity = await deps.rotateSourceIdentity();832 grantAttempts++;833 continue;834 }835836 if (!isAdoptIdentity(identity)) return null;837838 const device: PairedDevice = {839 deviceId: identity.device_id,840 deviceName: capDeviceName(identity.device_name),841 deviceType: identity.device_type,842 longTermPublicKeyHex: identity.long_term_public_key_hex,843 signingPublicKeyHex: identity.signing_public_key_hex,844 trustSource: "direct",845 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,846 relayUrl: deps.pairState?.relayUrl,847 pairedAt: Date.now(),848 };849850 851 852 853 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {854 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });855 const freshIdentity = await receiveFrame(channel);856 if (!isAdoptIdentity(freshIdentity)) return null;857 const freshDevice: PairedDevice = {858 deviceId: freshIdentity.device_id,859 deviceName: capDeviceName(freshIdentity.device_name),860 deviceType: freshIdentity.device_type,861 longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,862 signingPublicKeyHex: freshIdentity.signing_public_key_hex,863 trustSource: "direct",864 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,865 relayUrl: deps.pairState?.relayUrl,866 pairedAt: Date.now(),867 };868 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {869 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });870 return null;871 }872 await deps.upsertPairedDevice(freshDevice);873 await sendFrame(channel, { type: "ADOPT_COMPLETE" });874 if (!isAdoptApplied(await receiveFrame(channel))) return null;875 await deps.clearPendingHolder?.(request, freshDevice);876 return freshDevice;877 }878879 await deps.upsertPairedDevice(device);880 await sendFrame(channel, { type: "ADOPT_COMPLETE" });881 if (!isAdoptApplied(await receiveFrame(channel))) return null;882 await deps.clearPendingHolder?.(request, device);883 return device;884 }885}886887export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {888 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));889}890891export async function receiveFrame(channel: PairingTransport): Promise<unknown> {892 return JSON.parse(new TextDecoder().decode(await channel.receive()));893}894895function isDeviceType(value: unknown): value is DeviceType {896 return value === "extension" || value === "android" || value === "ios";897}898899function isSecureRelayUrl(value: string): boolean {900 try {901 const url = new URL(value);902 return url.protocol === "wss:" && !url.username && !url.password &&903 !url.search && !url.hash;904 } catch {905 return false;906 }907}908909function hexToBytes(hex: string): Uint8Array {910 const out = new Uint8Array(hex.length / 2);911 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);912 return out;913}