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 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257 deriveLocalIdentity(): Promise<LocalIdentity>;258 rotateLocalIdentity?(): Promise<LocalIdentity>;259 upsertPairedDevice(device: PairedDevice): Promise<void>;260 applyLicense?(license: string): Promise<void>;261 262 license?(): Promise<string | undefined>;263 264 pairState?: AdoptionPairState;265 onError(error: string): void;266}267268export interface AdoptionSourceDeps {269 sourceIdentity: LocalIdentity;270 rootKeyHex(): Promise<string>;271 vaultId(): Promise<string>;272 license?(): Promise<string | undefined>;273 274 applyLicense?(license: string): Promise<void>;275 onConsent(request: AdoptRequest): Promise<boolean>;276 upsertPairedDevice(device: PairedDevice): Promise<void>;277 278 pairState?: AdoptionPairState;279}280281export function isAdoptRequest(frame: unknown): frame is AdoptRequest {282 const value = frame as Partial<AdoptRequest> | null;283 return value?.type === "ADOPT_REQUEST" &&284 typeof value.device_id === "string" &&285 value.device_id.length > 0 &&286 typeof value.device_name === "string" &&287 isDeviceType(value.device_type) &&288 (value.license === undefined || typeof value.license === "string");289}290291export function isAdoptGrant(frame: unknown): frame is AdoptGrant {292 const value = frame as Partial<AdoptGrant> | null;293 return value?.type === "ADOPT_GRANT" &&294 validateRootKeyHex(value.root_key_hex) &&295 typeof value.vault_id === "string" &&296 /^[0-9a-f]{64}$/.test(value.vault_id) &&297 typeof value.source === "object" &&298 value.source !== null &&299 typeof value.source.device_id === "string" &&300 typeof value.source.device_name === "string" &&301 isDeviceType(value.source.device_type) &&302 typeof value.source.long_term_public_key_hex === "string" &&303 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&304 (value.source.relay_url === undefined ||305 (typeof value.source.relay_url === "string" &&306 isSecureRelayUrl(value.source.relay_url))) &&307 (value.snapshot_capable === undefined ||308 typeof value.snapshot_capable === "boolean");309}310311export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {312 const value = frame as Partial<AdoptConfirm> | null;313 return value?.type === "ADOPT_CONFIRM" &&314 typeof value.confirmed === "boolean";315}316317export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {318 const value = frame as Partial<AdoptIdentity> | null;319 return value?.type === "ADOPT_IDENTITY" &&320 typeof value.device_id === "string" &&321 value.device_id.length > 0 &&322 typeof value.device_name === "string" &&323 isDeviceType(value.device_type) &&324 typeof value.long_term_public_key_hex === "string" &&325 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);326}327328export function isAdoptComplete(frame: unknown): frame is AdoptComplete {329 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";330}331332export function isAdoptApplied(frame: unknown): frame is AdoptApplied {333 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";334}335336export function isAdoptReject(frame: unknown): frame is AdoptReject {337 const value = frame as Partial<AdoptReject> | null;338 return value?.type === "ADOPT_REJECT" &&339 (value.reason === "ADOPT_PRO_REQUIRED" ||340 value.reason === "ADOPT_DEVICE_ID_COLLISION");341}342343export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {344 const value = frame as Partial<AdoptSnapshot> | null;345 if (value?.type !== "ADOPT_SNAPSHOT") return false;346 if (347 typeof value.chunk_index !== "number" ||348 !Number.isInteger(value.chunk_index) ||349 value.chunk_index < 0350 ) return false;351 if (352 typeof value.chunk_count !== "number" ||353 !Number.isInteger(value.chunk_count) ||354 value.chunk_count < 1355 ) return false;356 if (!isStringRecord(value.entries)) return false;357 if (!isNumberRecord(value.tombstones)) return false;358 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;359 return true;360}361362export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {363 const value = frame as Partial<AdoptSnapshotApplied> | null;364 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&365 typeof value.entries_received === "number" &&366 typeof value.tombstones_received === "number";367}368369export function isAdoptDeviceManifestFrame(370 frame: unknown,371): frame is AdoptDeviceManifestFrame {372 const value = frame as Partial<AdoptDeviceManifestFrame> | null;373 const manifest = value?.manifest as374 | { body?: unknown; signatureHex?: unknown }375 | null;376 return value?.type === "ADOPT_DEVICE_MANIFEST" &&377 typeof manifest === "object" &&378 manifest !== null &&379 typeof manifest.body === "object" &&380 manifest.body !== null &&381 typeof manifest.signatureHex === "string";382}383384function isStringRecord(value: unknown): value is Record<string, string> {385 if (typeof value !== "object" || value === null) return false;386 return Object.values(value).every((v) => typeof v === "string");387}388389function isNumberRecord(value: unknown): value is Record<string, number> {390 if (typeof value !== "object" || value === null) return false;391 return Object.values(value).every((v) => typeof v === "number");392}393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428export async function sendVaultSnapshot(429 channel: PairingTransport,430 entries: Record<string, string>,431 tombstones: Record<string, number>,432 duressSlot: string | null = null,433): Promise<void> {434 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);435 436 437 438 439 440 const acc = createAdoptSnapshotBudget();441 const encoded: Uint8Array[] = frames.map((frame) =>442 new TextEncoder().encode(JSON.stringify(frame))443 );444 for (let index = 0; index < frames.length; index++) {445 const frame = frames[index];446 applyAdoptSnapshotFrame(447 acc,448 frame.entries,449 frame.tombstones,450 frame.duress_slot,451 encoded[index].length,452 );453 }454 455 456 457 for (const raw of encoded) await channel.send(padSyncFrame(raw));458}459460461462463464465function buildAdoptSnapshotFrames(466 entries: Record<string, string>,467 tombstones: Record<string, number>,468 duressSlot: string | null,469): AdoptSnapshot[] {470 const includeDuress = !!duressSlot;471 472 473 474 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;475476 const chunks: Array<Record<string, string>> = [];477 let current: Record<string, string> = {};478 let currentCount = 0;479 let currentBytes = 2; 480 for (const [id, envelope] of Object.entries(entries)) {481 482 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;483 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {484 chunks.push(current);485 current = {};486 currentCount = 0;487 currentBytes = 2;488 }489 current[id] = envelope;490 currentCount++;491 currentBytes += entryBytes;492 }493 chunks.push(current);494495 const entriesChunkCount = Math.min(chunks.length, entryCap);496 const sendDuress = includeDuress;497 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);498 const duressFrame: AdoptSnapshot | null = sendDuress499 ? {500 type: "ADOPT_SNAPSHOT",501 chunk_index: entriesChunkCount,502 chunk_count: totalChunks,503 entries: {},504 tombstones,505 duress_slot: duressSlot as string,506 }507 : null;508509 if (510 duressFrame &&511 new TextEncoder().encode(JSON.stringify(duressFrame)).length >512 SNAPSHOT_FRAME_WIRE_CEILING513 ) {514 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");515 }516517 const frames: AdoptSnapshot[] = [];518 for (let index = 0; index < entriesChunkCount; index++) {519 const isFinalEntries = index === entriesChunkCount - 1;520 frames.push({521 type: "ADOPT_SNAPSHOT",522 chunk_index: index,523 chunk_count: totalChunks,524 entries: chunks[index],525 526 527 tombstones: isFinalEntries && !sendDuress ? tombstones : {},528 });529 }530531 if (duressFrame) frames.push(duressFrame);532 return frames;533}534535536537538539540541542543544545546export async function receiveVaultSnapshot(547 channel: PairingTransport,548): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {549 const acc = createAdoptSnapshotBudget();550 let chunkCount = -1;551 let received = 0;552 while (chunkCount === -1 || received < chunkCount) {553 const wire = await channel.receive();554 if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {555 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");556 }557 558 559 const raw = unpadSyncFrame(wire);560 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {561 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");562 }563 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {564 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");565 }566 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));567 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");568 if (chunkCount === -1) {569 chunkCount = frame.chunk_count;570 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {571 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");572 }573 } else if (frame.chunk_count !== chunkCount) {574 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");575 }576 if (frame.chunk_index !== received) {577 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");578 }579 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);580 received++;581 }582 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };583}584585export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {586 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);587}588589export interface AdoptionRequestResult {590 device: PairedDevice;591 592 snapshotCapable: boolean;593}594595export async function requestAdoption(596 channel: PairingTransport,597 deps: AdoptionReceiverDeps,598): Promise<AdoptionRequestResult | null> {599 const receiverLicense = await deps.license?.();600 await sendFrame(channel, {601 type: "ADOPT_REQUEST",602 device_id: deps.generateDeviceId(),603 device_name: deps.deviceName,604 device_type: "extension",605 license: receiverLicense,606 snapshot_capable: true,607 });608609 const confirmed = await deps.confirmSas();610 await sendFrame(channel, {611 type: "ADOPT_CONFIRM",612 confirmed,613 });614 if (!confirmed) {615 deps.onError("ADOPT_SAS_REJECTED");616 return null;617 }618619 const frame = await receiveFrame(channel);620 if (isAdoptReject(frame)) {621 deps.onError(frame.reason);622 return null;623 }624 if (!isAdoptGrant(frame)) {625 deps.onError("ADOPT_UNEXPECTED_FRAME");626 return null;627 }628 const grantSnapshotCapable = frame.snapshot_capable === true;629630 const rootKeyBytes = hexToBytes(frame.root_key_hex);631 try {632 const computedVaultId = await computeVaultId(rootKeyBytes);633 if (computedVaultId !== frame.vault_id) {634 deps.onError("ADOPT_VAULT_ID_MISMATCH");635 return null;636 }637 } finally {638 rootKeyBytes.fill(0);639 }640 if (!receiverLicense && !frame.license) {641 deps.onError("ADOPT_PRO_REQUIRED");642 return null;643 }644645 await deps.validateSource?.(frame.source);646 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);647 if (vaultId !== frame.vault_id) {648 deps.onError("ADOPT_VAULT_ID_MISMATCH");649 return null;650 }651652 if (!receiverLicense && frame.license) {653 await deps.applyLicense?.(frame.license);654 }655656 let identity = await deps.deriveLocalIdentity();657 await sendFrame(channel, {658 type: "ADOPT_IDENTITY",659 device_id: identity.deviceId,660 device_name: identity.deviceName,661 device_type: identity.deviceType,662 long_term_public_key_hex: identity.longTermPublicKeyHex,663 signing_public_key_hex: identity.signingPublicKeyHex,664 });665666 let completion = await receiveFrame(channel);667 if (668 isAdoptReject(completion) &&669 completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&670 deps.rotateLocalIdentity671 ) {672 identity = await deps.rotateLocalIdentity();673 await sendFrame(channel, {674 type: "ADOPT_IDENTITY",675 device_id: identity.deviceId,676 device_name: identity.deviceName,677 device_type: identity.deviceType,678 long_term_public_key_hex: identity.longTermPublicKeyHex,679 signing_public_key_hex: identity.signingPublicKeyHex,680 });681 completion = await receiveFrame(channel);682 }683 if (!isAdoptComplete(completion)) {684 deps.onError("ADOPT_COMPLETION_MISSING");685 return null;686 }687688 const sourceDevice: PairedDevice = {689 deviceId: frame.source.device_id,690 deviceName: capDeviceName(frame.source.device_name),691 deviceType: frame.source.device_type,692 longTermPublicKeyHex: frame.source.long_term_public_key_hex,693 signingPublicKeyHex: frame.source.signing_public_key_hex,694 trustSource: "direct",695 wsPort: frame.source.ws_port,696 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,697 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,698 pairedAt: Date.now(),699 };700 await deps.upsertPairedDevice(sourceDevice);701 await sendFrame(channel, { type: "ADOPT_APPLIED" });702 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };703}704705export async function grantAdoption(706 channel: PairingTransport,707 request: AdoptRequest,708 deps: AdoptionSourceDeps,709): Promise<PairedDevice | null> {710 const approved = await deps.onConsent(request);711 if (!approved) return null;712713 const confirmation = await receiveFrame(channel);714 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;715716 let sourceLicense = await deps.license?.();717 if (!sourceLicense && request.license) {718 try {719 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");720 await deps.applyLicense(request.license);721 sourceLicense = await deps.license?.();722 } catch {723 sourceLicense = undefined;724 }725 }726 if (!sourceLicense) {727 await sendFrame(channel, {728 type: "ADOPT_REJECT",729 reason: "ADOPT_PRO_REQUIRED",730 });731 throw new Error("ADOPT_PRO_REQUIRED");732 }733734 const rootKeyHex = await deps.rootKeyHex();735 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");736737 await sendFrame(channel, {738 type: "ADOPT_GRANT",739 root_key_hex: rootKeyHex,740 vault_id: await deps.vaultId(),741 license: sourceLicense,742 source: {743 device_id: deps.sourceIdentity.deviceId,744 device_name: deps.sourceIdentity.deviceName,745 device_type: deps.sourceIdentity.deviceType,746 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,747 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,748 ws_port: deps.sourceIdentity.wsPort,749 relay_url: deps.pairState?.relayUrl,750 },751 snapshot_capable: request.snapshot_capable === true,752 });753754 const identity = await receiveFrame(channel);755 if (!isAdoptIdentity(identity)) return null;756757 const device: PairedDevice = {758 deviceId: identity.device_id,759 deviceName: capDeviceName(identity.device_name),760 deviceType: identity.device_type,761 longTermPublicKeyHex: identity.long_term_public_key_hex,762 signingPublicKeyHex: identity.signing_public_key_hex,763 trustSource: "direct",764 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,765 relayUrl: deps.pairState?.relayUrl,766 pairedAt: Date.now(),767 };768 await deps.upsertPairedDevice(device);769 await sendFrame(channel, { type: "ADOPT_COMPLETE" });770 if (!isAdoptApplied(await receiveFrame(channel))) return null;771 return device;772}773774export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {775 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));776}777778export async function receiveFrame(channel: PairingTransport): Promise<unknown> {779 return JSON.parse(new TextDecoder().decode(await channel.receive()));780}781782function isDeviceType(value: unknown): value is DeviceType {783 return value === "extension" || value === "android" || value === "ios";784}785786function isSecureRelayUrl(value: string): boolean {787 try {788 const url = new URL(value);789 return url.protocol === "wss:" && !url.username && !url.password &&790 !url.search && !url.hash;791 } catch {792 return false;793 }794}795796function hexToBytes(hex: string): Uint8Array {797 const out = new Uint8Array(hex.length / 2);798 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);799 return out;800}