vault-adopt.ts

22.5 KB · raw

1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";3import type { LocalIdentity, PairingTransport } from "./pairing-handshake";45export interface AdoptRequest {6  type: "ADOPT_REQUEST";7  device_id: string;8  device_name: string;9  device_type: DeviceType;10  /** Signed Pro entitlement offered by the receiver, if it has one. */11  license?: string;12  /**13   * Receiver advertises inline-snapshot support. A source that understands it14   * sends the vault snapshot over this same channel after ADOPT_APPLIED;15   * older sources ignore it and the receiver falls back to the sync pipeline.16   */17  snapshot_capable?: boolean;18}1920export interface AdoptGrant {21  type: "ADOPT_GRANT";22  root_key_hex: string;23  vault_id: string;24  license?: string;25  source: {26    device_id: string;27    device_name: string;28    device_type: DeviceType;29    long_term_public_key_hex: string;30    signing_public_key_hex?: string;31    ws_port?: number;32    relay_url?: string;33  };34  /**35   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so36   * the receiver can skip the inline-snapshot wait when the source will not37   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.38   */39  snapshot_capable?: boolean;40}4142export interface AdoptConfirm {43  type: "ADOPT_CONFIRM";44  confirmed: boolean;45}4647export interface AdoptIdentity {48  type: "ADOPT_IDENTITY";49  device_id: string;50  device_name: string;51  device_type: DeviceType;52  long_term_public_key_hex: string;53  signing_public_key_hex?: string;54}5556/**57 * Encrypted source-to-receiver acknowledgement sent only after the source has58 * durably recorded the receiver. Without it the receiver could report success59 * after sending ADOPT_IDENTITY even though the source never received it.60 */61export interface AdoptComplete {62  type: "ADOPT_COMPLETE";63}6465/** Receiver-to-source acknowledgement sent after the source record is durable. */66export interface AdoptApplied {67  type: "ADOPT_APPLIED";68}6970/** Neither peer could prove a Pro entitlement, so no vault state may commit. */71export interface AdoptReject {72  type: "ADOPT_REJECT";73  reason: "ADOPT_PRO_REQUIRED";74}7576/**77 * One chunk of the inline vault snapshot sent by the source after78 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are79 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.80 */81export interface AdoptSnapshot {82  type: "ADOPT_SNAPSHOT";83  chunk_index: number;84  chunk_count: number;85  entries: Record<string, string>;86  /** entryId → deletion timestamp; populated on the final chunk only. */87  tombstones: Record<string, number>;88  /**89   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser90   * shape — identical to the Codex vault_b field. Populated on the final91   * chunk only, and only when duress is actually configured; absent otherwise.92   */93  duress_slot?: string;94}9596/** Receiver acknowledgement sent after all snapshot chunks are applied. */97export interface AdoptSnapshotApplied {98  type: "ADOPT_SNAPSHOT_APPLIED";99  entries_received: number;100  tombstones_received: number;101}102103/** Optional signed device graph appended after the snapshot (manifest v3+). */104export interface AdoptDeviceManifestFrame {105  type: "ADOPT_DEVICE_MANIFEST";106  manifest: SignedDeviceManifest;107}108109/** Receiver acknowledgement for an applied device manifest. */110export interface AdoptDeviceManifestApplied {111  type: "ADOPT_DEVICE_MANIFEST_APPLIED";112}113114export type AdoptionFrame =115  | AdoptRequest116  | AdoptConfirm117  | AdoptGrant118  | AdoptIdentity119  | AdoptComplete120  | AdoptApplied121  | AdoptReject122  | AdoptSnapshot123  | AdoptSnapshotApplied124  | AdoptDeviceManifestFrame125  | AdoptDeviceManifestApplied;126127/**128 * Wire guardrails for the inline snapshot. A chunk's serialized entries map129 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at130 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver131 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).132 */133export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB134export const MAX_SNAPSHOT_CHUNKS = 200;135// Headroom added to the per-chunk entries budget when validating an encoded136// frame on the wire: JSON envelope plus the final chunk's tombstone map.137const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138139export interface AdoptionPairState {140  rendezvousSecretHex: string;141  relayUrl?: string;142}143144export interface AdoptionReceiverDeps {145  deviceName: string;146  generateDeviceId(): string;147  /** Root-key transfer cannot begin until this device confirms the SAS. */148  confirmSas(): Promise<boolean>;149  /**150   * Optional local safety policy evaluated after the authenticated grant is151   * validated but before any vault state changes.152   */153  validateSource?(source: AdoptGrant["source"]): Promise<void>;154  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;155  deriveLocalIdentity(): Promise<LocalIdentity>;156  upsertPairedDevice(device: PairedDevice): Promise<void>;157  applyLicense?(license: string): Promise<void>;158  /** Return this receiver's locally validated Pro key, if present. */159  license?(): Promise<string | undefined>;160  /** Bound to the same completed Noise session, never taken from frame claims. */161  pairState?: AdoptionPairState;162  onError(error: string): void;163}164165export interface AdoptionSourceDeps {166  sourceIdentity: LocalIdentity;167  rootKeyHex(): Promise<string>;168  vaultId(): Promise<string>;169  license?(): Promise<string | undefined>;170  /** Validate and durably store a Pro key offered by the receiver. */171  applyLicense?(license: string): Promise<void>;172  onConsent(request: AdoptRequest): Promise<boolean>;173  upsertPairedDevice(device: PairedDevice): Promise<void>;174  /** Bound to the same completed Noise session, never taken from frame claims. */175  pairState?: AdoptionPairState;176}177178export function isAdoptRequest(frame: unknown): frame is AdoptRequest {179  const value = frame as Partial<AdoptRequest> | null;180  return value?.type === "ADOPT_REQUEST" &&181    typeof value.device_id === "string" &&182    value.device_id.length > 0 &&183    typeof value.device_name === "string" &&184    isDeviceType(value.device_type) &&185    (value.license === undefined || typeof value.license === "string");186}187188export function isAdoptGrant(frame: unknown): frame is AdoptGrant {189  const value = frame as Partial<AdoptGrant> | null;190  return value?.type === "ADOPT_GRANT" &&191    validateRootKeyHex(value.root_key_hex) &&192    typeof value.vault_id === "string" &&193    /^[0-9a-f]{64}$/.test(value.vault_id) &&194    typeof value.source === "object" &&195    value.source !== null &&196    typeof value.source.device_id === "string" &&197    typeof value.source.device_name === "string" &&198    isDeviceType(value.source.device_type) &&199    typeof value.source.long_term_public_key_hex === "string" &&200    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&201    (value.source.relay_url === undefined ||202      (typeof value.source.relay_url === "string" &&203        isSecureRelayUrl(value.source.relay_url))) &&204    (value.snapshot_capable === undefined ||205      typeof value.snapshot_capable === "boolean");206}207208export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {209  const value = frame as Partial<AdoptConfirm> | null;210  return value?.type === "ADOPT_CONFIRM" &&211    typeof value.confirmed === "boolean";212}213214export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {215  const value = frame as Partial<AdoptIdentity> | null;216  return value?.type === "ADOPT_IDENTITY" &&217    typeof value.device_id === "string" &&218    value.device_id.length > 0 &&219    typeof value.device_name === "string" &&220    isDeviceType(value.device_type) &&221    typeof value.long_term_public_key_hex === "string" &&222    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);223}224225export function isAdoptComplete(frame: unknown): frame is AdoptComplete {226  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";227}228229export function isAdoptApplied(frame: unknown): frame is AdoptApplied {230  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";231}232233export function isAdoptReject(frame: unknown): frame is AdoptReject {234  const value = frame as Partial<AdoptReject> | null;235  return value?.type === "ADOPT_REJECT" &&236    value.reason === "ADOPT_PRO_REQUIRED";237}238239export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {240  const value = frame as Partial<AdoptSnapshot> | null;241  if (value?.type !== "ADOPT_SNAPSHOT") return false;242  if (243    typeof value.chunk_index !== "number" ||244    !Number.isInteger(value.chunk_index) ||245    value.chunk_index < 0246  ) return false;247  if (248    typeof value.chunk_count !== "number" ||249    !Number.isInteger(value.chunk_count) ||250    value.chunk_count < 1251  ) return false;252  if (!isStringRecord(value.entries)) return false;253  if (!isNumberRecord(value.tombstones)) return false;254  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;255  return true;256}257258export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {259  const value = frame as Partial<AdoptSnapshotApplied> | null;260  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&261    typeof value.entries_received === "number" &&262    typeof value.tombstones_received === "number";263}264265export function isAdoptDeviceManifestFrame(266  frame: unknown,267): frame is AdoptDeviceManifestFrame {268  const value = frame as Partial<AdoptDeviceManifestFrame> | null;269  const manifest = value?.manifest as270    | { body?: unknown; signatureHex?: unknown }271    | null;272  return value?.type === "ADOPT_DEVICE_MANIFEST" &&273    typeof manifest === "object" &&274    manifest !== null &&275    typeof manifest.body === "object" &&276    manifest.body !== null &&277    typeof manifest.signatureHex === "string";278}279280export function isAdoptDeviceManifestApplied(281  frame: unknown,282): frame is AdoptDeviceManifestApplied {283  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===284    "ADOPT_DEVICE_MANIFEST_APPLIED";285}286287function isStringRecord(value: unknown): value is Record<string, string> {288  if (typeof value !== "object" || value === null) return false;289  return Object.values(value).every((v) => typeof v === "string");290}291292function isNumberRecord(value: unknown): value is Record<string, number> {293  if (typeof value !== "object" || value === null) return false;294  return Object.values(value).every((v) => typeof v === "number");295}296297/**298 * Source side: chunk the vault snapshot and send it over the pairing channel.299 *300 * Wire shape (v4.6.4+, duress on its own frame):301 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.302 *     Tombstones ride the last entries frame when no duress follows.303 *   - If duressSlot is set, one extra frame after the entries carrying only304 *     the duress slot (entries: empty, tombstones: tombstone map,305 *     duress_slot: duressSlot). This moves duress off the entries frame so a306 *     sizeable sealed duress vault can no longer push the entries frame raw307 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).308 *309 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):310 *   - When duress is configured, ONE of the 200 frames is reserved for the311 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries312 *     chunks; excess entries are deferred to the sync fallback pipeline (sync313 *     carries entries and tombstones, so this is a graceful truncation).314 *     This reservation is REQUIRED: ordinary sync does NOT carry duress315 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently316 *     dropping a duress frame at the cap would leave the adopted device317 *     permanently without its duress vault. We prioritize duress delivery318 *     over completeness of entries (the latter is recoverable via sync).319 *320 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING321 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the322 * first duress frame is sent. This is a hard stop — the caller must split323 * the duress payload out of band. We do not silently truncate duress; sync324 * cannot restore it.325 *326 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries327 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`328 * reads `duress_slot` off any chunk (a duress-only frame has empty329 * entries/tombstones which merge as a no-op).330 */331export async function sendVaultSnapshot(332  channel: PairingTransport,333  entries: Record<string, string>,334  tombstones: Record<string, number>,335  duressSlot: string | null = null,336): Promise<void> {337  const includeDuress = !!duressSlot;338  // Reserve one frame for duress when configured so duress is never silently339  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a340  // duress frame would orphan the adopted device's duress vault.341  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;342343  const chunks: Array<Record<string, string>> = [];344  let current: Record<string, string> = {};345  let currentBytes = 2; // "{}"346  for (const [id, envelope] of Object.entries(entries)) {347    // +1 for the separating comma; an overestimate only shrinks chunks.348    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;349    if (350      Object.keys(current).length > 0 &&351      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES352    ) {353      chunks.push(current);354      current = {};355      currentBytes = 2;356    }357    current[id] = envelope;358    currentBytes += entryBytes;359  }360  chunks.push(current);361362  const entriesChunkCount = Math.min(chunks.length, entryCap);363  const sendDuress = includeDuress;364  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);365  const duressFrame: AdoptSnapshot | null = sendDuress366    ? {367        type: "ADOPT_SNAPSHOT",368        chunk_index: entriesChunkCount,369        chunk_count: totalChunks,370        entries: {},371        tombstones,372        duress_slot: duressSlot as string,373      }374    : null;375376  if (377    duressFrame &&378    new TextEncoder().encode(JSON.stringify(duressFrame)).length >379      SNAPSHOT_FRAME_WIRE_CEILING380  ) {381    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");382  }383384  for (let index = 0; index < entriesChunkCount; index++) {385    const isFinalEntries = index === entriesChunkCount - 1;386    const frame: AdoptSnapshot = {387      type: "ADOPT_SNAPSHOT",388      chunk_index: index,389      chunk_count: totalChunks,390      entries: chunks[index],391      // Tombstones ride the LAST wire frame: when duress follows the entries,392      // it goes on the duress frame; otherwise on the last entries chunk.393      tombstones: isFinalEntries && !sendDuress ? tombstones : {},394    };395    await sendFrame(channel, frame);396  }397398  if (duressFrame) await sendFrame(channel, duressFrame);399}400401/**402 * Receiver side: receive and reassemble every snapshot chunk. Validates the403 * encoded frame size before parsing, then enforces sequential 0-based404 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and405 * structural validity of each frame. Throws on any validation failure so the406 * caller can fall back to the sync pipeline.407 */408export async function receiveVaultSnapshot(409  channel: PairingTransport,410): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {411  const entries: Record<string, string> = {};412  const tombstones: Record<string, number> = {};413  let duressSlot: string | null = null;414  let chunkCount = -1;415  let received = 0;416  while (chunkCount === -1 || received < chunkCount) {417    const raw = await channel.receive();418    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {419      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");420    }421    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));422    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");423    if (chunkCount === -1) {424      chunkCount = frame.chunk_count;425      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {426        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");427      }428    } else if (frame.chunk_count !== chunkCount) {429      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");430    }431    if (frame.chunk_index !== received) {432      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");433    }434    Object.assign(entries, frame.entries);435    Object.assign(tombstones, frame.tombstones);436    if (frame.duress_slot) duressSlot = frame.duress_slot;437    received++;438  }439  return { entries, tombstones, duressSlot };440}441442export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {443  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);444}445446export interface AdoptionRequestResult {447  device: PairedDevice;448  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */449  snapshotCapable: boolean;450}451452export async function requestAdoption(453  channel: PairingTransport,454  deps: AdoptionReceiverDeps,455): Promise<AdoptionRequestResult | null> {456  const receiverLicense = await deps.license?.();457  await sendFrame(channel, {458    type: "ADOPT_REQUEST",459    device_id: deps.generateDeviceId(),460    device_name: deps.deviceName,461    device_type: "extension",462    license: receiverLicense,463    snapshot_capable: true,464  });465466  const confirmed = await deps.confirmSas();467  await sendFrame(channel, {468    type: "ADOPT_CONFIRM",469    confirmed,470  });471  if (!confirmed) {472    deps.onError("ADOPT_SAS_REJECTED");473    return null;474  }475476  const frame = await receiveFrame(channel);477  if (isAdoptReject(frame)) {478    deps.onError(frame.reason);479    return null;480  }481  if (!isAdoptGrant(frame)) {482    deps.onError("ADOPT_UNEXPECTED_FRAME");483    return null;484  }485  const grantSnapshotCapable = frame.snapshot_capable === true;486487  const rootKeyBytes = hexToBytes(frame.root_key_hex);488  try {489    const computedVaultId = await computeVaultId(rootKeyBytes);490    if (computedVaultId !== frame.vault_id) {491      deps.onError("ADOPT_VAULT_ID_MISMATCH");492      return null;493    }494  } finally {495    rootKeyBytes.fill(0);496  }497  if (!receiverLicense && !frame.license) {498    deps.onError("ADOPT_PRO_REQUIRED");499    return null;500  }501502  await deps.validateSource?.(frame.source);503  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);504  if (vaultId !== frame.vault_id) {505    deps.onError("ADOPT_VAULT_ID_MISMATCH");506    return null;507  }508509  if (!receiverLicense && frame.license) {510    await deps.applyLicense?.(frame.license);511  }512513  const identity = await deps.deriveLocalIdentity();514  await sendFrame(channel, {515    type: "ADOPT_IDENTITY",516    device_id: identity.deviceId,517    device_name: identity.deviceName,518    device_type: identity.deviceType,519    long_term_public_key_hex: identity.longTermPublicKeyHex,520    signing_public_key_hex: identity.signingPublicKeyHex,521  });522523  const completion = await receiveFrame(channel);524  if (!isAdoptComplete(completion)) {525    deps.onError("ADOPT_COMPLETION_MISSING");526    return null;527  }528529  const sourceDevice: PairedDevice = {530    deviceId: frame.source.device_id,531    deviceName: frame.source.device_name,532    deviceType: frame.source.device_type,533    longTermPublicKeyHex: frame.source.long_term_public_key_hex,534    signingPublicKeyHex: frame.source.signing_public_key_hex,535    trustSource: "direct",536    wsPort: frame.source.ws_port,537    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,538    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,539    pairedAt: Date.now(),540  };541  await deps.upsertPairedDevice(sourceDevice);542  await sendFrame(channel, { type: "ADOPT_APPLIED" });543  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };544}545546export async function grantAdoption(547  channel: PairingTransport,548  request: AdoptRequest,549  deps: AdoptionSourceDeps,550): Promise<PairedDevice | null> {551  const approved = await deps.onConsent(request);552  if (!approved) return null;553554  const confirmation = await receiveFrame(channel);555  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;556557  let sourceLicense = await deps.license?.();558  if (!sourceLicense && request.license) {559    try {560      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");561      await deps.applyLicense(request.license);562      sourceLicense = await deps.license?.();563    } catch {564      sourceLicense = undefined;565    }566  }567  if (!sourceLicense) {568    await sendFrame(channel, {569      type: "ADOPT_REJECT",570      reason: "ADOPT_PRO_REQUIRED",571    });572    throw new Error("ADOPT_PRO_REQUIRED");573  }574575  const rootKeyHex = await deps.rootKeyHex();576  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");577578  await sendFrame(channel, {579    type: "ADOPT_GRANT",580    root_key_hex: rootKeyHex,581    vault_id: await deps.vaultId(),582    license: sourceLicense,583    source: {584      device_id: deps.sourceIdentity.deviceId,585      device_name: deps.sourceIdentity.deviceName,586      device_type: deps.sourceIdentity.deviceType,587      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,588      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,589      ws_port: deps.sourceIdentity.wsPort,590      relay_url: deps.pairState?.relayUrl,591    },592    snapshot_capable: request.snapshot_capable === true,593  });594595  const identity = await receiveFrame(channel);596  if (!isAdoptIdentity(identity)) return null;597598  const device: PairedDevice = {599    deviceId: identity.device_id,600    deviceName: identity.device_name,601    deviceType: identity.device_type,602    longTermPublicKeyHex: identity.long_term_public_key_hex,603    signingPublicKeyHex: identity.signing_public_key_hex,604    trustSource: "direct",605    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,606    relayUrl: deps.pairState?.relayUrl,607    pairedAt: Date.now(),608  };609  await deps.upsertPairedDevice(device);610  await sendFrame(channel, { type: "ADOPT_COMPLETE" });611  if (!isAdoptApplied(await receiveFrame(channel))) return null;612  return device;613}614615export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {616  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));617}618619export async function receiveFrame(channel: PairingTransport): Promise<unknown> {620  return JSON.parse(new TextDecoder().decode(await channel.receive()));621}622623function isDeviceType(value: unknown): value is DeviceType {624  return value === "extension" || value === "android" || value === "ios";625}626627function isSecureRelayUrl(value: string): boolean {628  try {629    const url = new URL(value);630    return url.protocol === "wss:" && !url.username && !url.password &&631      !url.search && !url.hash;632  } catch {633    return false;634  }635}636637function hexToBytes(hex: string): Uint8Array {638  const out = new Uint8Array(hex.length / 2);639  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);640  return out;641}