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 upsertPairedDevice(device: PairedDevice): Promise<void>;283 284285286287288 hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;289 290291292293294 rotateSourceIdentity?(): Promise<LocalIdentity>;295 296 pairState?: AdoptionPairState;297}298299export function isAdoptRequest(frame: unknown): frame is AdoptRequest {300 const value = frame as Partial<AdoptRequest> | null;301 return value?.type === "ADOPT_REQUEST" &&302 typeof value.device_id === "string" &&303 value.device_id.length > 0 &&304 typeof value.device_name === "string" &&305 isDeviceType(value.device_type) &&306 (value.license === undefined || typeof value.license === "string");307}308309export function isAdoptGrant(frame: unknown): frame is AdoptGrant {310 const value = frame as Partial<AdoptGrant> | null;311 return value?.type === "ADOPT_GRANT" &&312 validateRootKeyHex(value.root_key_hex) &&313 typeof value.vault_id === "string" &&314 /^[0-9a-f]{64}$/.test(value.vault_id) &&315 typeof value.source === "object" &&316 value.source !== null &&317 typeof value.source.device_id === "string" &&318 typeof value.source.device_name === "string" &&319 isDeviceType(value.source.device_type) &&320 typeof value.source.long_term_public_key_hex === "string" &&321 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&322 (value.source.relay_url === undefined ||323 (typeof value.source.relay_url === "string" &&324 isSecureRelayUrl(value.source.relay_url))) &&325 (value.snapshot_capable === undefined ||326 typeof value.snapshot_capable === "boolean");327}328329export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {330 const value = frame as Partial<AdoptConfirm> | null;331 return value?.type === "ADOPT_CONFIRM" &&332 typeof value.confirmed === "boolean";333}334335export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {336 const value = frame as Partial<AdoptIdentity> | null;337 return value?.type === "ADOPT_IDENTITY" &&338 typeof value.device_id === "string" &&339 value.device_id.length > 0 &&340 typeof value.device_name === "string" &&341 isDeviceType(value.device_type) &&342 typeof value.long_term_public_key_hex === "string" &&343 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);344}345346export function isAdoptComplete(frame: unknown): frame is AdoptComplete {347 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";348}349350export function isAdoptApplied(frame: unknown): frame is AdoptApplied {351 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";352}353354export function isAdoptReject(frame: unknown): frame is AdoptReject {355 const value = frame as Partial<AdoptReject> | null;356 return value?.type === "ADOPT_REJECT" &&357 (value.reason === "ADOPT_PRO_REQUIRED" ||358 value.reason === "ADOPT_DEVICE_ID_COLLISION");359}360361export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {362 const value = frame as Partial<AdoptSnapshot> | null;363 if (value?.type !== "ADOPT_SNAPSHOT") return false;364 if (365 typeof value.chunk_index !== "number" ||366 !Number.isInteger(value.chunk_index) ||367 value.chunk_index < 0368 ) return false;369 if (370 typeof value.chunk_count !== "number" ||371 !Number.isInteger(value.chunk_count) ||372 value.chunk_count < 1373 ) return false;374 if (!isStringRecord(value.entries)) return false;375 if (!isNumberRecord(value.tombstones)) return false;376 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;377 return true;378}379380export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {381 const value = frame as Partial<AdoptSnapshotApplied> | null;382 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&383 typeof value.entries_received === "number" &&384 typeof value.tombstones_received === "number";385}386387export function isAdoptDeviceManifestFrame(388 frame: unknown,389): frame is AdoptDeviceManifestFrame {390 const value = frame as Partial<AdoptDeviceManifestFrame> | null;391 const manifest = value?.manifest as392 | { body?: unknown; signatureHex?: unknown }393 | null;394 return value?.type === "ADOPT_DEVICE_MANIFEST" &&395 typeof manifest === "object" &&396 manifest !== null &&397 typeof manifest.body === "object" &&398 manifest.body !== null &&399 typeof manifest.signatureHex === "string";400}401402function isStringRecord(value: unknown): value is Record<string, string> {403 if (typeof value !== "object" || value === null) return false;404 return Object.values(value).every((v) => typeof v === "string");405}406407function isNumberRecord(value: unknown): value is Record<string, number> {408 if (typeof value !== "object" || value === null) return false;409 return Object.values(value).every((v) => typeof v === "number");410}411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446export async function sendVaultSnapshot(447 channel: PairingTransport,448 entries: Record<string, string>,449 tombstones: Record<string, number>,450 duressSlot: string | null = null,451): Promise<void> {452 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);453 454 455 456 457 458 const acc = createAdoptSnapshotBudget();459 const encoded: Uint8Array[] = frames.map((frame) =>460 new TextEncoder().encode(JSON.stringify(frame))461 );462 for (let index = 0; index < frames.length; index++) {463 const frame = frames[index];464 applyAdoptSnapshotFrame(465 acc,466 frame.entries,467 frame.tombstones,468 frame.duress_slot,469 encoded[index].length,470 );471 }472 473 474 475 for (const raw of encoded) await channel.send(padSyncFrame(raw));476}477478479480481482483function buildAdoptSnapshotFrames(484 entries: Record<string, string>,485 tombstones: Record<string, number>,486 duressSlot: string | null,487): AdoptSnapshot[] {488 const includeDuress = !!duressSlot;489 490 491 492 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;493494 const chunks: Array<Record<string, string>> = [];495 let current: Record<string, string> = {};496 let currentCount = 0;497 let currentBytes = 2; 498 for (const [id, envelope] of Object.entries(entries)) {499 500 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;501 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {502 chunks.push(current);503 current = {};504 currentCount = 0;505 currentBytes = 2;506 }507 current[id] = envelope;508 currentCount++;509 currentBytes += entryBytes;510 }511 chunks.push(current);512513 const entriesChunkCount = Math.min(chunks.length, entryCap);514 const sendDuress = includeDuress;515 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);516 const duressFrame: AdoptSnapshot | null = sendDuress517 ? {518 type: "ADOPT_SNAPSHOT",519 chunk_index: entriesChunkCount,520 chunk_count: totalChunks,521 entries: {},522 tombstones,523 duress_slot: duressSlot as string,524 }525 : null;526527 if (528 duressFrame &&529 new TextEncoder().encode(JSON.stringify(duressFrame)).length >530 SNAPSHOT_FRAME_WIRE_CEILING531 ) {532 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");533 }534535 const frames: AdoptSnapshot[] = [];536 for (let index = 0; index < entriesChunkCount; index++) {537 const isFinalEntries = index === entriesChunkCount - 1;538 frames.push({539 type: "ADOPT_SNAPSHOT",540 chunk_index: index,541 chunk_count: totalChunks,542 entries: chunks[index],543 544 545 tombstones: isFinalEntries && !sendDuress ? tombstones : {},546 });547 }548549 if (duressFrame) frames.push(duressFrame);550 return frames;551}552553554555556557558559560561562563564export async function receiveVaultSnapshot(565 channel: PairingTransport,566): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {567 const acc = createAdoptSnapshotBudget();568 let chunkCount = -1;569 let received = 0;570 while (chunkCount === -1 || received < chunkCount) {571 const wire = await channel.receive();572 if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {573 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");574 }575 576 577 const raw = unpadSyncFrame(wire);578 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {579 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");580 }581 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {582 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");583 }584 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));585 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");586 if (chunkCount === -1) {587 chunkCount = frame.chunk_count;588 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {589 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");590 }591 } else if (frame.chunk_count !== chunkCount) {592 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");593 }594 if (frame.chunk_index !== received) {595 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");596 }597 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);598 received++;599 }600 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };601}602603export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {604 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);605}606607export interface AdoptionRequestResult {608 device: PairedDevice;609 610 snapshotCapable: boolean;611}612613export async function requestAdoption(614 channel: PairingTransport,615 deps: AdoptionReceiverDeps,616): Promise<AdoptionRequestResult | null> {617 const receiverLicense = await deps.license?.();618 await sendFrame(channel, {619 type: "ADOPT_REQUEST",620 device_id: deps.generateDeviceId(),621 device_name: deps.deviceName,622 device_type: "extension",623 license: receiverLicense,624 snapshot_capable: true,625 });626627 const confirmed = await deps.confirmSas();628 await sendFrame(channel, {629 type: "ADOPT_CONFIRM",630 confirmed,631 });632 if (!confirmed) {633 deps.onError("ADOPT_SAS_REJECTED");634 return null;635 }636637 const frame = await receiveFrame(channel);638 if (isAdoptReject(frame)) {639 deps.onError(frame.reason);640 return null;641 }642 if (!isAdoptGrant(frame)) {643 deps.onError("ADOPT_UNEXPECTED_FRAME");644 return null;645 }646 const grantSnapshotCapable = frame.snapshot_capable === true;647648 const rootKeyBytes = hexToBytes(frame.root_key_hex);649 try {650 const computedVaultId = await computeVaultId(rootKeyBytes);651 if (computedVaultId !== frame.vault_id) {652 deps.onError("ADOPT_VAULT_ID_MISMATCH");653 return null;654 }655 } finally {656 rootKeyBytes.fill(0);657 }658 if (!receiverLicense && !frame.license) {659 deps.onError("ADOPT_PRO_REQUIRED");660 return null;661 }662663 await deps.validateSource?.(frame.source);664665 666 667 668 669 670 let grant = frame;671 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {672 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });673 const retry = await receiveFrame(channel);674 if (!isAdoptGrant(retry)) {675 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");676 return null;677 }678 grant = retry;679 if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {680 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });681 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");682 return null;683 }684 685 const freshRootBytes = hexToBytes(grant.root_key_hex);686 try {687 if (await computeVaultId(freshRootBytes) !== grant.vault_id) {688 deps.onError("ADOPT_VAULT_ID_MISMATCH");689 return null;690 }691 } finally {692 freshRootBytes.fill(0);693 }694 }695696 const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);697 if (vaultId !== grant.vault_id) {698 deps.onError("ADOPT_VAULT_ID_MISMATCH");699 return null;700 }701702 if (!receiverLicense && grant.license) {703 await deps.applyLicense?.(grant.license);704 }705706 let identity = await deps.deriveLocalIdentity();707 await sendFrame(channel, {708 type: "ADOPT_IDENTITY",709 device_id: identity.deviceId,710 device_name: identity.deviceName,711 device_type: identity.deviceType,712 long_term_public_key_hex: identity.longTermPublicKeyHex,713 signing_public_key_hex: identity.signingPublicKeyHex,714 });715716 let completion = await receiveFrame(channel);717 if (718 isAdoptReject(completion) &&719 completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&720 deps.rotateLocalIdentity721 ) {722 identity = await deps.rotateLocalIdentity();723 await sendFrame(channel, {724 type: "ADOPT_IDENTITY",725 device_id: identity.deviceId,726 device_name: identity.deviceName,727 device_type: identity.deviceType,728 long_term_public_key_hex: identity.longTermPublicKeyHex,729 signing_public_key_hex: identity.signingPublicKeyHex,730 });731 completion = await receiveFrame(channel);732 }733 if (!isAdoptComplete(completion)) {734 deps.onError("ADOPT_COMPLETION_MISSING");735 return null;736 }737738 const sourceDevice: PairedDevice = {739 deviceId: grant.source.device_id,740 deviceName: capDeviceName(grant.source.device_name),741 deviceType: grant.source.device_type,742 longTermPublicKeyHex: grant.source.long_term_public_key_hex,743 signingPublicKeyHex: grant.source.signing_public_key_hex,744 trustSource: "direct",745 wsPort: grant.source.ws_port,746 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,747 relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,748 pairedAt: Date.now(),749 };750 await deps.upsertPairedDevice(sourceDevice);751 await sendFrame(channel, { type: "ADOPT_APPLIED" });752 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };753}754755export async function grantAdoption(756 channel: PairingTransport,757 request: AdoptRequest,758 deps: AdoptionSourceDeps,759): Promise<PairedDevice | null> {760 const approved = await deps.onConsent(request);761 if (!approved) return null;762763 const confirmation = await receiveFrame(channel);764 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;765766 let sourceLicense = await deps.license?.();767 if (!sourceLicense && request.license) {768 try {769 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");770 await deps.applyLicense(request.license);771 sourceLicense = await deps.license?.();772 } catch {773 sourceLicense = undefined;774 }775 }776 if (!sourceLicense) {777 await sendFrame(channel, {778 type: "ADOPT_REJECT",779 reason: "ADOPT_PRO_REQUIRED",780 });781 throw new Error("ADOPT_PRO_REQUIRED");782 }783784 const rootKeyHex = await deps.rootKeyHex();785 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");786787 let sourceIdentity = deps.sourceIdentity;788 let grantAttempts = 0;789 while (true) {790 await sendFrame(channel, {791 type: "ADOPT_GRANT",792 root_key_hex: rootKeyHex,793 vault_id: await deps.vaultId(),794 license: sourceLicense,795 source: {796 device_id: sourceIdentity.deviceId,797 device_name: sourceIdentity.deviceName,798 device_type: sourceIdentity.deviceType,799 long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,800 signing_public_key_hex: sourceIdentity.signingPublicKeyHex,801 ws_port: sourceIdentity.wsPort,802 relay_url: deps.pairState?.relayUrl,803 },804 snapshot_capable: request.snapshot_capable === true,805 });806807 const identity = await receiveFrame(channel);808809 810 811 if (812 isAdoptReject(identity) &&813 identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&814 deps.rotateSourceIdentity &&815 grantAttempts === 0816 ) {817 sourceIdentity = await deps.rotateSourceIdentity();818 grantAttempts++;819 continue;820 }821822 if (!isAdoptIdentity(identity)) return null;823824 const device: PairedDevice = {825 deviceId: identity.device_id,826 deviceName: capDeviceName(identity.device_name),827 deviceType: identity.device_type,828 longTermPublicKeyHex: identity.long_term_public_key_hex,829 signingPublicKeyHex: identity.signing_public_key_hex,830 trustSource: "direct",831 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,832 relayUrl: deps.pairState?.relayUrl,833 pairedAt: Date.now(),834 };835836 837 838 839 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {840 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });841 const freshIdentity = await receiveFrame(channel);842 if (!isAdoptIdentity(freshIdentity)) return null;843 const freshDevice: PairedDevice = {844 deviceId: freshIdentity.device_id,845 deviceName: capDeviceName(freshIdentity.device_name),846 deviceType: freshIdentity.device_type,847 longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,848 signingPublicKeyHex: freshIdentity.signing_public_key_hex,849 trustSource: "direct",850 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,851 relayUrl: deps.pairState?.relayUrl,852 pairedAt: Date.now(),853 };854 if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {855 await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });856 return null;857 }858 await deps.upsertPairedDevice(freshDevice);859 await sendFrame(channel, { type: "ADOPT_COMPLETE" });860 if (!isAdoptApplied(await receiveFrame(channel))) return null;861 return freshDevice;862 }863864 await deps.upsertPairedDevice(device);865 await sendFrame(channel, { type: "ADOPT_COMPLETE" });866 if (!isAdoptApplied(await receiveFrame(channel))) return null;867 return device;868 }869}870871export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {872 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));873}874875export async function receiveFrame(channel: PairingTransport): Promise<unknown> {876 return JSON.parse(new TextDecoder().decode(await channel.receive()));877}878879function isDeviceType(value: unknown): value is DeviceType {880 return value === "extension" || value === "android" || value === "ios";881}882883function isSecureRelayUrl(value: string): boolean {884 try {885 const url = new URL(value);886 return url.protocol === "wss:" && !url.username && !url.password &&887 !url.search && !url.hash;888 } catch {889 return false;890 }891}892893function hexToBytes(hex: string): Uint8Array {894 const out = new Uint8Array(hex.length / 2);895 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);896 return out;897}