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}4849export interface AdoptIdentity {50 type: "ADOPT_IDENTITY";51 device_id: string;52 device_name: string;53 device_type: DeviceType;54 long_term_public_key_hex: string;55 signing_public_key_hex?: string;56}57585960616263export interface AdoptComplete {64 type: "ADOPT_COMPLETE";65}666768export interface AdoptApplied {69 type: "ADOPT_APPLIED";70}717273export interface AdoptReject {74 type: "ADOPT_REJECT";75 reason: "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";76}77787980818283export interface AdoptSnapshot {84 type: "ADOPT_SNAPSHOT";85 chunk_index: number;86 chunk_count: number;87 entries: Record<string, string>;88 89 tombstones: Record<string, number>;90 9192939495 duress_slot?: string;96}979899export interface AdoptSnapshotApplied {100 type: "ADOPT_SNAPSHOT_APPLIED";101 entries_received: number;102 tombstones_received: number;103}104105106export interface AdoptDeviceManifestFrame {107 type: "ADOPT_DEVICE_MANIFEST";108 manifest: SignedDeviceManifest;109}110111112export interface AdoptDeviceManifestApplied {113 type: "ADOPT_DEVICE_MANIFEST_APPLIED";114}115116export type AdoptionFrame =117 | AdoptRequest118 | AdoptConfirm119 | AdoptGrant120 | AdoptIdentity121 | AdoptComplete122 | AdoptApplied123 | AdoptReject124 | AdoptSnapshot125 | AdoptSnapshotApplied126 | AdoptDeviceManifestFrame127 | AdoptDeviceManifestApplied;128129130131132133134135export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 136export const MAX_SNAPSHOT_CHUNKS = 200;137138139export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;140141142143144145export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(146 SNAPSHOT_FRAME_WIRE_CEILING + 4,147);148149150151152153154155156157158export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; 159export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;160export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;161162export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;163164165166167export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =168 (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;169170171172173174export interface AdoptSnapshotBudget {175 entries: Record<string, string>;176 tombstones: Record<string, number>;177 duressSlot: string | null;178 179 bytes: number;180 entryCount: number;181 tombstoneCount: number;182}183184export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {185 return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };186}187188189190191192193194195196197198export function applyAdoptSnapshotFrame(199 acc: AdoptSnapshotBudget,200 entries: Record<string, string>,201 tombstones: Record<string, number>,202 duressSlot: string | null | undefined,203 frameBytes: number,204): void {205 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {206 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");207 }208 for (const [id, envelope] of Object.entries(entries)) {209 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");210 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {211 throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");212 }213 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {214 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");215 }216 acc.entryCount++;217 acc.entries[id] = envelope;218 }219 for (const [id, ts] of Object.entries(tombstones)) {220 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");221 if (typeof ts !== "number" || !Number.isFinite(ts)) {222 throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");223 }224 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {225 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");226 }227 acc.tombstoneCount++;228 acc.tombstones[id] = ts;229 }230 if (typeof duressSlot === "string" && duressSlot.length > 0) {231 232 233 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {234 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");235 }236 acc.duressSlot = duressSlot;237 }238 acc.bytes += frameBytes;239}240241export interface AdoptionPairState {242 rendezvousSecretHex: string;243 relayUrl?: string;244}245246export interface AdoptionReceiverDeps {247 deviceName: string;248 generateDeviceId(): string;249 250 confirmSas(): Promise<boolean>;251 252253254255 validateSource?(source: AdoptGrant["source"]): Promise<void>;256 257258259260261 hasSourceIdentityCollision?(source: AdoptGrant["source"]): Promise<boolean>;262 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;263 deriveLocalIdentity(): Promise<LocalIdentity>;264 rotateLocalIdentity?(): Promise<LocalIdentity>;265 upsertPairedDevice(device: PairedDevice): Promise<void>;266 applyLicense?(license: string): Promise<void>;267 268 license?(): Promise<string | undefined>;269 270 pairState?: AdoptionPairState;271 onError(error: string): void;272}273274export interface AdoptionSourceDeps {275 sourceIdentity: LocalIdentity;276 rootKeyHex(): Promise<string>;277 vaultId(): Promise<string>;278 license?(): Promise<string | undefined>;279 280 applyLicense?(license: string): Promise<void>;281 onConsent(request: AdoptRequest): Promise<boolean>;282 283 upsertPairedDevice(device: PairedDevice): Promise<void>;284 285 persistPendingHolder?(request: AdoptRequest): Promise<void>;286 287 clearPendingHolder?(request: AdoptRequest, device: PairedDevice): Promise<void>288 289290291292293 hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;294 295296297298299 rotateSourceIdentity?(): Promise<LocalIdentity>;300 301 pairState?: AdoptionPairState;302}303304export function isAdoptRequest(frame: unknown): frame is AdoptRequest {305 const value = frame as Partial<AdoptRequest> | null;306 return value?.type === "ADOPT_REQUEST" &&307 typeof value.device_id === "string" &&308 value.device_id.length > 0 &&309 typeof value.device_name === "string" &&310 isDeviceType(value.device_type) &&311 (value.license === undefined || typeof value.license === "string");312}313314export function isAdoptGrant(frame: unknown): frame is AdoptGrant {315 const value = frame as Partial<AdoptGrant> | null;316 return value?.type === "ADOPT_GRANT" &&317 validateRootKeyHex(value.root_key_hex) &&318 typeof value.vault_id === "string" &&319 /^[0-9a-f]{64}$/.test(value.vault_id) &&320 typeof value.source === "object" &&321 value.source !== null &&322 typeof value.source.device_id === "string" &&323 typeof value.source.device_name === "string" &&324 isDeviceType(value.source.device_type) &&325 typeof value.source.long_term_public_key_hex === "string" &&326 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&327 (value.source.relay_url === undefined ||328 (typeof value.source.relay_url === "string" &&329 isSecureRelayUrl(value.source.relay_url))) &&330 (value.snapshot_capable === undefined ||331 typeof value.snapshot_capable === "boolean");332}333334export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {335 const value = frame as Partial<AdoptConfirm> | null;336 return value?.type === "ADOPT_CONFIRM" &&337 typeof value.confirmed === "boolean";338}339340export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {341 const value = frame as Partial<AdoptIdentity> | null;342 return value?.type === "ADOPT_IDENTITY" &&343 typeof value.device_id === "string" &&344 value.device_id.length > 0 &&345 typeof value.device_name === "string" &&346 isDeviceType(value.device_type) &&347 typeof value.long_term_public_key_hex === "string" &&348 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);349}350351export function isAdoptComplete(frame: unknown): frame is AdoptComplete {352 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";353}354355export function isAdoptApplied(frame: unknown): frame is AdoptApplied {356 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";357}358359export function isAdoptReject(frame: unknown): frame is AdoptReject {360 const value = frame as Partial<AdoptReject> | null;361 return value?.type === "ADOPT_REJECT" &&362 (value.reason === "ADOPT_PRO_REQUIRED" ||363 value.reason === "ADOPT_DEVICE_ID_COLLISION");364}365366export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {367 const value = frame as Partial<AdoptSnapshot> | null;368 if (value?.type !== "ADOPT_SNAPSHOT") return false;369 if (370 typeof value.chunk_index !== "number" ||371 !Number.isInteger(value.chunk_index) ||372 value.chunk_index < 0373 ) return false;374 if (375 typeof value.chunk_count !== "number" ||376 !Number.isInteger(value.chunk_count) ||377 value.chunk_count < 1378 ) return false;379 if (!isStringRecord(value.entries)) return false;380 if (!isNumberRecord(value.tombstones)) return false;381 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;382 return true;383}384385export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {386 const value = frame as Partial<AdoptSnapshotApplied> | null;387 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&388 typeof value.entries_received === "number" &&389 typeof value.tombstones_received === "number";390}391392export function isAdoptDeviceManifestFrame(393 frame: unknown,394): frame is AdoptDeviceManifestFrame {395 const value = frame as Partial<AdoptDeviceManifestFrame> | null;396 const manifest = value?.manifest as397 | { body?: unknown; signatureHex?: unknown }398 | null;399 return value?.type === "ADOPT_DEVICE_MANIFEST" &&400 typeof manifest === "object" &&401 manifest !== null &&402 typeof manifest.body === "object" &&403 manifest.body !== null &&404 typeof manifest.signatureHex === "string";405}406407function isStringRecord(value: unknown): value is Record<string, string> {408 if (typeof value !== "object" || value === null) return false;409 return Object.values(value).every((v) => typeof v === "string");410}411412function isNumberRecord(value: unknown): value is Record<string, number> {413 if (typeof value !== "object" || value === null) return false;414 return Object.values(value).every((v) => typeof v === "number");415}416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451export async function sendVaultSnapshot(452 channel: PairingTransport,453 entries: Record<string, string>,454 tombstones: Record<string, number>,455 duressSlot: string | null = null,456): Promise<void> {457 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);458 459 460 461 462 463 const acc = createAdoptSnapshotBudget();464 const encoded: Uint8Array[] = frames.map((frame) =>465 new TextEncoder().encode(JSON.stringify(frame))466 );467 for (let index = 0; index < frames.length; index++) {468 const frame = frames[index];469 applyAdoptSnapshotFrame(470 acc,471 frame.entries,472 frame.tombstones,473 frame.duress_slot,474 encoded[index].length,475 );476 }477 478 479 480 for (const raw of encoded) await channel.send(padSyncFrame(raw));481}482483484485486487488function buildAdoptSnapshotFrames(489 entries: Record<string, string>,490 tombstones: Record<string, number>,491 duressSlot: string | null,492): AdoptSnapshot[] {493 const includeDuress = !!duressSlot;494 495 496 497 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;498499 const chunks: Array<Record<string, string>> = [];500 let current: Record<string, string> = {};501 let currentCount = 0;502 let currentBytes = 2; 503 for (const [id, envelope] of Object.entries(entries)) {504 505 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;506 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {507 chunks.push(current);508 current = {};509 currentCount = 0;510 currentBytes = 2;511 }512 current[id] = envelope;513 currentCount++;514 currentBytes += entryBytes;515 }516 chunks.push(current);517518 const entriesChunkCount = Math.min(chunks.length, entryCap);519 const sendDuress = includeDuress;520 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);521 const duressFrame: AdoptSnapshot | null = sendDuress522 ? {523 type: "ADOPT_SNAPSHOT",524 chunk_index: entriesChunkCount,525 chunk_count: totalChunks,526 entries: {},527 tombstones,528 duress_slot: duressSlot as string,529 }530 : null;531532 if (533 duressFrame &&534 new TextEncoder().encode(JSON.stringify(duressFrame)).length >535 SNAPSHOT_FRAME_WIRE_CEILING536 ) {537 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");538 }539540 const frames: AdoptSnapshot[] = [];541 for (let index = 0; index < entriesChunkCount; index++) {542 const isFinalEntries = index === entriesChunkCount - 1;543 frames.push({544 type: "ADOPT_SNAPSHOT",545 chunk_index: index,546 chunk_count: totalChunks,547 entries: chunks[index],548 549 550 tombstones: isFinalEntries && !sendDuress ? tombstones : {},551 });552 }553554 if (duressFrame) frames.push(duressFrame);555 return frames;556}557558559560561562563564565566567568569export async function receiveVaultSnapshot(570 channel: PairingTransport,571): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {572 const acc = createAdoptSnapshotBudget();573 let chunkCount = -1;574 let received = 0;575 while (chunkCount === -1 || received < chunkCount) {576 const wire = await channel.receive();577 if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {578 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");579 }580 581 582 const raw = unpadSyncFrame(wire);583 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {584 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");585 }586 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {587 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");588 }589 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));590 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");591 if (chunkCount === -1) {592 chunkCount = frame.chunk_count;593 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {594 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");595 }596 } else if (frame.chunk_count !== chunkCount) {597 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");598 }599 if (frame.chunk_index !== received) {600 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");601 }602 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);603 received++;604 }605 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };606}607608export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {609 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);610}611612export interface AdoptionRequestResult {613 device: PairedDevice;614 615 snapshotCapable: boolean;616}617618export async function requestAdoption(619 channel: PairingTransport,620 deps: AdoptionReceiverDeps,621): Promise<AdoptionRequestResult | null> {622 const receiverLicense = await deps.license?.();623 await sendFrame(channel, {624 type: "ADOPT_REQUEST",625 device_id: deps.generateDeviceId(),626 device_name: deps.deviceName,627 device_type: "extension",628 license: receiverLicense,629 snapshot_capable: true,630 });631632 const confirmed = await deps.confirmSas();633 await sendFrame(channel, {634 type: "ADOPT_CONFIRM",635 confirmed,636 });637 if (!confirmed) {638 deps.onError("ADOPT_SAS_REJECTED");639 return null;640 }641642 const frame = await receiveFrame(channel);643 if (isAdoptReject(frame)) {644 deps.onError(frame.reason);645 return null;646 }647 if (!isAdoptGrant(frame)) {648 deps.onError("ADOPT_UNEXPECTED_FRAME");649 return null;650 }651 const grantSnapshotCapable = frame.snapshot_capable === true;652653 const rootKeyBytes = hexToBytes(frame.root_key_hex);654 try {655 const computedVaultId = await computeVaultId(rootKeyBytes);656 if (computedVaultId !== frame.vault_id) {657 deps.onError("ADOPT_VAULT_ID_MISMATCH");658 return null;659 }660 } finally {661 rootKeyBytes.fill(0);662 }663 if (!receiverLicense && !frame.license) {664 deps.onError("ADOPT_PRO_REQUIRED");665 return null;666 }667668 await deps.validateSource?.(frame.source);669670 671 672 673 674 675 let grant = frame;676 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {677 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });678 const retry = await receiveFrame(channel);679 if (!isAdoptGrant(retry)) {680 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");681 return null;682 }683 grant = retry;684 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {685 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });686 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");687 return null;688 }689 690 const freshRootBytes = hexToBytes(grant.root_key_hex);691 try {692 if (await computeVaultId(freshRootBytes) !== grant.vault_id) {693 deps.onError("ADOPT_VAULT_ID_MISMATCH");694 return null;695 }696 } finally {697 freshRootBytes.fill(0);698 }699 }700701 const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);702 if (vaultId !== grant.vault_id) {703 deps.onError("ADOPT_VAULT_ID_MISMATCH");704 return null;705 }706707 if (!receiverLicense && grant.license) {708 await deps.applyLicense?.(grant.license);709 }710711 let identity = await deps.deriveLocalIdentity();712 await sendFrame(channel, {713 type: "ADOPT_IDENTITY",714 device_id: identity.deviceId,715 device_name: identity.deviceName,716 device_type: identity.deviceType,717 long_term_public_key_hex: identity.longTermPublicKeyHex,718 signing_public_key_hex: identity.signingPublicKeyHex,719 });720721 let completion = await receiveFrame(channel);722 if (723 isAdoptReject(completion) &&724 completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&725 deps.rotateLocalIdentity726 ) {727 identity = await deps.rotateLocalIdentity();728 await sendFrame(channel, {729 type: "ADOPT_IDENTITY",730 device_id: identity.deviceId,731 device_name: identity.deviceName,732 device_type: identity.deviceType,733 long_term_public_key_hex: identity.longTermPublicKeyHex,734 signing_public_key_hex: identity.signingPublicKeyHex,735 });736 completion = await receiveFrame(channel);737 }738 if (!isAdoptComplete(completion)) {739 deps.onError("ADOPT_COMPLETION_MISSING");740 return null;741 }742743 const sourceDevice: PairedDevice = {744 deviceId: grant.source.device_id,745 deviceName: capDeviceName(grant.source.device_name),746 deviceType: grant.source.device_type,747 longTermPublicKeyHex: grant.source.long_term_public_key_hex,748 signingPublicKeyHex: grant.source.signing_public_key_hex,749 trustSource: "direct",750 wsPort: grant.source.ws_port,751 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,752 relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,753 pairedAt: Date.now(),754 };755 await deps.upsertPairedDevice(sourceDevice);756 await sendFrame(channel, { type: "ADOPT_APPLIED" });757 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };758}759760export async function grantAdoption(761 channel: PairingTransport,762 request: AdoptRequest,763 deps: AdoptionSourceDeps,764): Promise<PairedDevice | null> {765 const approved = await deps.onConsent(request);766 if (!approved) return null;767 const confirmation = await receiveFrame(channel);768 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;769 await deps.persistPendingHolder?.(request);770771 let sourceLicense = await deps.license?.();772 if (!sourceLicense && request.license) {773 try {774 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");775 await deps.applyLicense(request.license);776 sourceLicense = await deps.license?.();777 } catch {778 sourceLicense = undefined;779 }780 }781 if (!sourceLicense) {782 await sendFrame(channel, {783 type: "ADOPT_REJECT",784 reason: "ADOPT_PRO_REQUIRED",785 });786 throw new Error("ADOPT_PRO_REQUIRED");787 }788789 const rootKeyHex = await deps.rootKeyHex();790 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");791792 let sourceIdentity = deps.sourceIdentity;793 let grantAttempts = 0;794 while (true) {795 await sendFrame(channel, {796 type: "ADOPT_GRANT",797 root_key_hex: rootKeyHex,798 vault_id: await deps.vaultId(),799 license: sourceLicense,800 source: {801 device_id: sourceIdentity.deviceId,802 device_name: sourceIdentity.deviceName,803 device_type: sourceIdentity.deviceType,804 long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,805 signing_public_key_hex: sourceIdentity.signingPublicKeyHex,806 ws_port: sourceIdentity.wsPort,807 relay_url: deps.pairState?.relayUrl,808 },809 snapshot_capable: request.snapshot_capable === true,810 });811812 const identity = await receiveFrame(channel);813814 815 816 if (817 isAdoptReject(identity) &&818 identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&819 deps.rotateSourceIdentity &&820 grantAttempts === 0821 ) {822 sourceIdentity = await deps.rotateSourceIdentity();823 grantAttempts++;824 continue;825 }826827 if (!isAdoptIdentity(identity)) return null;828829 const device: PairedDevice = {830 deviceId: identity.device_id,831 deviceName: capDeviceName(identity.device_name),832 deviceType: identity.device_type,833 longTermPublicKeyHex: identity.long_term_public_key_hex,834 signingPublicKeyHex: identity.signing_public_key_hex,835 trustSource: "direct",836 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,837 relayUrl: deps.pairState?.relayUrl,838 pairedAt: Date.now(),839 };840841 842 843 844 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {845 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });846 const freshIdentity = await receiveFrame(channel);847 if (!isAdoptIdentity(freshIdentity)) return null;848 const freshDevice: PairedDevice = {849 deviceId: freshIdentity.device_id,850 deviceName: capDeviceName(freshIdentity.device_name),851 deviceType: freshIdentity.device_type,852 longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,853 signingPublicKeyHex: freshIdentity.signing_public_key_hex,854 trustSource: "direct",855 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,856 relayUrl: deps.pairState?.relayUrl,857 pairedAt: Date.now(),858 };859 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {860 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });861 return null;862 }863 await deps.upsertPairedDevice(freshDevice);864 await sendFrame(channel, { type: "ADOPT_COMPLETE" });865 if (!isAdoptApplied(await receiveFrame(channel))) return null;866 await deps.clearPendingHolder?.(request, freshDevice);867 return freshDevice;868 }869870 await deps.upsertPairedDevice(device);871 await sendFrame(channel, { type: "ADOPT_COMPLETE" });872 if (!isAdoptApplied(await receiveFrame(channel))) return null;873 await deps.clearPendingHolder?.(request, device);874 return device;875 }876}877878export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {879 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));880}881882export async function receiveFrame(channel: PairingTransport): Promise<unknown> {883 return JSON.parse(new TextDecoder().decode(await channel.receive()));884}885886function isDeviceType(value: unknown): value is DeviceType {887 return value === "extension" || value === "android" || value === "ios";888}889890function isSecureRelayUrl(value: string): boolean {891 try {892 const url = new URL(value);893 return url.protocol === "wss:" && !url.username && !url.password &&894 !url.search && !url.hash;895 } catch {896 return false;897 }898}899900function hexToBytes(hex: string): Uint8Array {901 const out = new Uint8Array(hex.length / 2);902 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);903 return out;904}