relay-adoption-bg.ts

24.9 KB · raw

1import { generateKeyPair } from "@stablelib/x25519";2import {3  createPairingInvitation,4  pairingInvitationUrl,5  parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8  openRelayPairingSocket,9  type PairingSocket,10} from "../shared/pairing-dial";11import {12  noiseNKResponder,13  type NoiseNKSession,14} from "../shared/noise-js";15import {16  grantAdoption,17  isAdoptDeviceManifestFrame,18  isAdoptRequest,19  isAdoptSnapshotApplied,20  receiveFrame,21  receiveVaultSnapshot,22  requestAdoption,23  sendFrame,24  sendVaultSnapshot,25  type AdoptRequest,26} from "../shared/vault-adopt";27import {28  derivePairingSAS,29  type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33  bytesToHex,34  deriveRendezvousSecret,35  generatePairingRendezvousToken,36} from "../shared/rendezvous";37import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";38import { getSyncSettings, relayInForce } from "./sync-settings";39import { activateLicense, getLicenseKey, isProUser } from "./license";40import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41import { computeVaultId } from "../shared/sync-identity";42import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";43import { storageRemove, storageSet } from "../shared/storage";44import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45import { isValidPairSecret, setPairSecret } from "./pair-secrets";46import { createSyncVaultAdapter } from "./sync-vault-adapter";47import { triggerSyncIfReachable } from "./sync-trigger";48import { schedulePostAdoptionSync } from "./sync-alarm";4950const SOURCE_WINDOW_MS = 5 * 60 * 1000;5152function push(type: string, payload: unknown): void {53  try {54    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;55    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {56      (maybe as Promise<unknown>).catch(() => undefined);57    }58  } catch {59    // The popup may have closed. The pending approval fails closed on cleanup.60  }61}6263/** Race a promise against a wall-clock deadline; rejects with "timeout". */64function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {65  return new Promise<T>((resolve, reject) => {66    const timer = setTimeout(() => reject(new Error("timeout")), ms);67    promise.then(68      (value) => {69        clearTimeout(timer);70        resolve(value);71      },72      (error) => {73        clearTimeout(timer);74        reject(error);75      },76    );77  });78}7980let inFlight = false;81let activeSocket: PairingSocket | null = null;82// Outstanding source-side SAS confirmations, keyed by id. A map (vs the83// receiver's single slot) makes a stale decision from a previous source84// session impossible to land on the current one even if both run on the85// same background worker.86const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();87const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8889export function isRelayAdoptionSourceInFlight(): boolean {90  return inFlight;91}9293/**94 * Occupy the non-buffering room before returning the invitation to the UI.95 * All long-term key material remains in the background service worker.96 */97export async function startRelayAdoptionSource(): Promise<{98  invitation: string;99}> {100  if (inFlight) throw new Error("adoption_source_in_progress");101  if (!isUnlocked()) throw new Error("vault_locked");102  inFlight = true;103104  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;105  try {106    const settings = await getSyncSettings();107    const relayUrl = relayInForce(settings);108    local = await getLocalPairingIdentity();109    const token = generatePairingRendezvousToken();110    // Source side has the vault — caller checked isUnlocked — so the phone111    // can read the invitation and know it should receive the vault.112    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));113    const invitation = parsePairingInvitation(encoded);114    if (!invitation) throw new Error("invitation_generation_failed");115116    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));117    activeSocket = socket;118    const readyLocal = local;119    void runSourceSession(120      socket,121      token,122      relayUrl,123      readyLocal,124    ).finally(() => {125      readyLocal.privateKey.fill(0);126      readyLocal.publicKey.fill(0);127      clearState();128    });129    return { invitation: encoded };130  } catch (error) {131    local?.privateKey.fill(0);132    local?.publicKey.fill(0);133    clearState();134    throw error;135  }136}137138export function cancelRelayAdoptionSource(): void {139  activeSocket?.close();140  clearState();141}142143/**144 * Onboarding pairing request — the empty extension shows a QR and the populated145 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the146 * phone reads to switch into source mode and `grantAdoption` over the147 * established Noise channel. The receiver's `runReceiverSession` accepts the148 * root key and creates a local vault wrapped with the popup-supplied password.149 *150 * `password` is the local-only password the user picked on the "Set a password151 * for this device" screen; it wraps the adopted root key at rest and has no152 * relationship to the source device's password.153 */154export async function startRelayAdoptionReceiver(155  password: string,156): Promise<{ invitation: string }> {157  if (inFlight) throw new Error("adoption_receiver_in_progress");158  if (typeof password !== "string" || password.length === 0) {159    throw new Error("ADOPT_INVALID_PASSWORD");160  }161  inFlight = true;162  try {163    const settings = await getSyncSettings();164    const relayUrl = relayInForce(settings);165    const token = generatePairingRendezvousToken();166    const ephemeral = generateKeyPair();167    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));168    const invitation = parsePairingInvitation(encoded);169    if (!invitation) throw new Error("invitation_generation_failed");170    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));171    activeSocket = socket;172    void runReceiverSession(173      socket,174      token,175      relayUrl,176      password,177      ephemeral,178    ).finally(() => {179      ephemeral.secretKey.fill(0);180      ephemeral.publicKey.fill(0);181      clearState();182    });183    return { invitation: encoded };184  } catch (error) {185    clearState();186    throw error;187  }188}189190/**191 * Resolves the source-side SAS confirmation by id. Called by the message192 * handler when the popup taps Confirm/Cancel on the source's SAS screen.193 * Returns true if the id matched an in-flight confirmation. Unknown ids194 * (already resolved, timed out, or never existed) are a no-op so a late195 * popup click cannot crash anything.196 */197export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {198  const pending = pendingSourceSasConfirmation.get(id);199  if (!pending) return false;200  pendingSourceSasConfirmation.delete(id);201  pending.resolve(ok);202  return true;203}204205export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {206  const pending = pendingReceiverSasConfirmation.get(id);207  if (!pending) return false;208  pendingReceiverSasConfirmation.delete(id);209  pending.resolve(ok && constantTimeEqual(pending.sas, sas));210  return true;211}212213async function runSourceSession(214  socket: PairingSocket,215  expectedToken: string,216  relayUrl: string,217  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,218): Promise<void> {219  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);220  let noise: NoiseNKSession | undefined;221  try {222    const hello = decodeJson<{ type?: unknown; token?: unknown }>(223      await socket.receive(),224    );225    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||226        !constantTimeEqual(hello.token, expectedToken)) {227      throw new Error("invalid_invitation");228    }229    socket.send(encodeJson({230      type: "PEACH_HELLO_ACK",231      ephemeralPublicKeyHex: bytesToHex(local.publicKey),232    }));233234    const session = await noiseNKResponder(235      local.privateKey,236      local.publicKey,237      async (bytes) => socket.send(bytes),238      () => socket.receive(),239    );240    noise = session;241    const channel: PairingTransport = {242      async send(plaintext) { socket.send(session.encrypt(plaintext)); },243      async receive() { return session.decrypt(await socket.receive()); },244    };245    const request = await receiveFrame(channel);246    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");247248    const device = await grantAdoption(channel, request, {249      sourceIdentity: local.identity,250      pairState: {251        rendezvousSecretHex: bytesToHex(252          deriveRendezvousSecret(session.handshakeHash),253        ),254        relayUrl,255      },256      async rootKeyHex() {257        // grantAdoption calls this only after local password approval and the258        // receiver's encrypted ADOPT_CONFIRM(true).259        const value = getRootKeyHex();260        if (!value) throw new Error("vault_locked");261        return value;262      },263      async vaultId() {264        const value = getRootKeyHex();265        if (!value) throw new Error("vault_locked");266        const bytes = hexToBytes(value);267        try {268          return await computeVaultId(bytes);269        } finally {270          bytes.fill(0);271        }272      },273      async license() {274        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;275      },276      async applyLicense(license) {277        await applyTransferredLicense(license);278      },279      onConsent: (adoptRequest) => requestConsent(280        adoptRequest,281        derivePairingSAS(session.handshakeHash),282        crypto.randomUUID(),283        session.handshakeHash,284      ),285      upsertPairedDevice: storeAdoptedDevice,286    });287    if (!device) {288      push("ADOPTION_SOURCE_RESULT", {289        status: "error",290        error: "adoption_rejected",291      });292      return;293    }294    // If the receiver advertised inline-snapshot support, send the vault now295    // over the same authenticated channel. Best-effort: any failure leaves the296    // receiver to fall back to the sync pipeline triggered below.297    if (request.snapshot_capable === true) {298      try {299        const adapter = createSyncVaultAdapter();300        const entryManifest = await adapter.buildEntryManifest();301        const entryIds = Object.keys(entryManifest);302        const entries =303          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};304        const tombstones = await adapter.buildTombstoneManifest();305        let duressSlot: string | null = null;306        try {307          duressSlot = await exportDuressSlotForAdoption();308        } catch {309          // Duress omission must not fail the whole snapshot.310        }311        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);312313        const applied = await withTimeout(receiveFrame(channel), 15_000);314        if (isAdoptSnapshotApplied(applied)) {315          console.info(316            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,317          );318        }319320        if (adapter.buildOutgoingDeviceManifest) {321          const manifest = await adapter.buildOutgoingDeviceManifest();322          if (manifest) {323            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });324            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);325          }326        }327      } catch (snapshotError) {328        if (329          snapshotError instanceof Error &&330          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"331        ) {332          throw snapshotError;333        }334        console.info(335          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",336          snapshotError,337        );338      }339    }340    push("ADOPTION_SOURCE_RESULT", { status: "complete" });341    // The Android receiver starts its relay listener as soon as its source342    // record is durable. Immediate best-effort trigger; alarm-based retries343    // cover MV3 service-worker death (setTimeout does not survive it).344    void triggerSyncIfReachable({ reason: "manual", force: true });345    schedulePostAdoptionSync();346  } catch (error) {347    push("ADOPTION_SOURCE_RESULT", {348      status: "error",349      error: error instanceof Error ? error.message : "adoption_failed",350    });351  } finally {352    clearTimeout(timeout);353    noise?.close();354    socket.close();355    pendingSourceSasConfirmation.clear();356  }357}358359/**360 * Browser receives the vault from the phone. The protocol is the mirror of361 * the source side: phone is the Noise initiator and the source of truth for362 * the SAS gate, browser is the responder that creates the vault locally.363 */364async function runReceiverSession(365  socket: PairingSocket,366  expectedToken: string,367  relayUrl: string,368  password: string,369  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },370): Promise<void> {371  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);372  // The invitation binds the responder static so the initiator can verify the ACK.373  const ephemeral = invitationEphemeral;374  const ephemeralPrivate = ephemeral.secretKey;375  const ephemeralPublic = ephemeral.publicKey;376  let noise: NoiseNKSession | undefined;377  let createdVault = false;378  let protocolError: string | undefined;379  try {380    const hello = decodeJson<{ type?: unknown; token?: unknown }>(381      await socket.receive(),382    );383    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||384        !constantTimeEqual(hello.token, expectedToken)) {385      throw new Error("invalid_invitation");386    }387    socket.send(encodeJson({388      type: "PEACH_HELLO_ACK",389      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),390    }));391392    const session = await noiseNKResponder(393      ephemeralPrivate,394      ephemeralPublic,395      async (bytes) => socket.send(bytes),396      () => socket.receive(),397    );398    noise = session;399    const channel: PairingTransport = {400      async send(plaintext) { socket.send(session.encrypt(plaintext)); },401      async receive() { return session.decrypt(await socket.receive()); },402    };403404    const deviceName = await getDeviceName();405406    const adoptionResult = await requestAdoption(channel, {407      deviceName,408      generateDeviceId: () => crypto.randomUUID(),409      async confirmSas() {410        const sas = derivePairingSAS(session.handshakeHash);411        const id = crypto.randomUUID();412        return waitForReceiverSasConfirmation(id, sas, session.handshakeHash);413      },414      async createVaultFromRootKey(rootKeyHex) {415        // Direct call, not chrome.runtime.sendMessage — a service worker416        // calling sendMessage does not reach its own listener (returns417        // undefined), and the receiver already lives in the same SW context418        // as the vault module. Round-tripping through the message bus was419        // the root cause of "create_vault_no_response".420        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);421        createdVault = true;422        return { vaultId };423      },424      async applyLicense(license) {425        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self426        // issue as createVaultFromRootKey. SW can't reach its own listener.427        await applyTransferredLicense(license);428      },429      async license() {430        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;431      },432      async deriveLocalIdentity() {433        // Direct call for the same reason as applyLicense above.434        const masterKeyHex = getRootKeyHex();435        if (!masterKeyHex) throw new Error("identity_unavailable");436        const masterKey = hexToBytes(masterKeyHex);437        try {438          return await buildLocalIdentity(masterKey);439        } finally {440          masterKey.fill(0);441        }442      },443      async upsertPairedDevice(paired) {444        // Direct call for the same reason as applyLicense above.445        await storeAdoptedDevice(paired);446      },447      pairState: {448        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),449        relayUrl,450      },451      onError(error) {452        protocolError = error;453      },454    });455456    if (!adoptionResult) {457      if (createdVault) {458        await rollbackAdoptedVault();459        createdVault = false;460      }461      push("ADOPTION_SOURCE_RESULT", {462        status: "error",463        error: protocolError ?? "adoption_rejected",464      });465      return;466    }467    const { device, snapshotCapable } = adoptionResult;468    // The pairing channel is still open and authenticated — try to pull the469    // vault snapshot inline so the popup transitions immediately instead of470    // waiting on a second sync round. Skip the wait entirely when the source471    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);472    // fall through to the existing sync pipeline below.473    let snapshotApplied = false;474    if (snapshotCapable) try {475      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);476      const adapter = createSyncVaultAdapter();477      await adapter.applyEntries(snapshot.entries);478      await adapter.applyTombstones(snapshot.tombstones);479      // Duress install is isolated: a failure here must NOT demote an480      // otherwise-successful entry snapshot to the sync fallback.481      if (snapshot.duressSlot) {482        try {483          await installAdoptedDuressSlot(snapshot.duressSlot);484        } catch (duressError) {485          console.info(486            "[Peach] adopted duress slot install failed (non-fatal):",487            duressError,488          );489        }490      }491      await sendFrame(channel, {492        type: "ADOPT_SNAPSHOT_APPLIED",493        entries_received: Object.keys(snapshot.entries).length,494        tombstones_received: Object.keys(snapshot.tombstones).length,495      });496497      // Optional device graph. Non-fatal and isolated so a manifest timeout or498      // unsupported source cannot demote an otherwise-successful entry snapshot.499      try {500        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);501        if (502          isAdoptDeviceManifestFrame(manifestFrame) &&503          adapter.applyIncomingDeviceManifest &&504          typeof device.longTermPublicKeyHex === "string" &&505          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)506        ) {507          await adapter.applyIncomingDeviceManifest(508            manifestFrame.manifest,509            hexToBytes(device.longTermPublicKeyHex),510          );511          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });512        }513      } catch {514        // The graph arrives via incremental sync instead.515      }516517      await markLastSyncedAt(device.deviceId, Date.now());518      snapshotApplied = true;519    } catch (snapshotError) {520      console.info(521        "[Peach] inline snapshot failed, falling back to sync pipeline:",522        snapshotError,523      );524    }525526    // Adoption has committed identity and vault-key material, but entries have527    // not necessarily crossed yet. Persist the exact source row before telling528    // the popup pairing is complete so a popup close/reopen cannot reveal the529    // temporarily empty receiver vault. The popup clears this only after that530    // row records a successful sync.531    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(532      () => undefined,533    );534    push("ADOPTION_SOURCE_RESULT", { status: "complete" });535    createdVault = false;536537    if (snapshotApplied) {538      // The snapshot populated the vault and marked the source synced, so the539      // pending marker's job is done — clear it instead of making the popup wait.540      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);541    } else {542      // Fallback: the existing sync pipeline fills the vault. Immediate543      // best-effort trigger plus alarm-based retries that survive SW death.544      void triggerSyncIfReachable({ reason: "manual", force: true });545      schedulePostAdoptionSync();546    }547  } catch (error) {548    if (createdVault) {549      try {550        await rollbackAdoptedVault();551        createdVault = false;552      } catch (rollbackError) {553        console.error("[Peach] rollback failed:", rollbackError);554      }555    }556    console.error("[Peach] receiver session failed:", error);557    push("ADOPTION_SOURCE_RESULT", {558      status: "error",559      error: error instanceof Error ? error.message : "adoption_failed",560    });561  } finally {562    clearTimeout(timeout);563    noise?.close();564    socket.close();565    ephemeralPrivate.fill(0);566    ephemeralPublic.fill(0);567  }568}569570/**571 * A transferred entitlement is committed only when activation succeeds and572 * the stored value can immediately be re-read as Pro. activateLicense returns573 * a normal Free result for invalid input, so callers must not ignore it.574 */575async function applyTransferredLicense(license: string): Promise<void> {576  const activated = await activateLicense(license);577  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {578    throw new Error("license_transfer_failed");579  }580}581582/**583 * Push the SAS to the popup, then block until the popup responds via the584 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on585 * timeout or cancel so the adoption aborts before any vault state changes.586 *587 * The pending deferred MUST be installed before the push: a popup-side588 * auto-approver (test mocks, future automation) can synchronously resolve589 * the deferred the moment it sees the SAS push, and a missing slot would590 * drop that approval on the floor.591 */592function pairingSessionFingerprint(handshakeHash: Uint8Array): string {593  return bytesToHex(handshakeHash).slice(0, 12).match(/.{4}/g)?.join(" ") ?? "";594}595596function waitForReceiverSasConfirmation(597  id: string,598  sas: string,599  handshakeHash: Uint8Array,600): Promise<boolean> {601  return new Promise<boolean>((resolve) => {602    const timer = setTimeout(() => {603      pendingReceiverSasConfirmation.delete(id);604      resolve(false);605    }, SOURCE_WINDOW_MS);606    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {607      clearTimeout(timer);608      resolve(ok);609    } });610    push("ADOPTION_SOURCE_SAS", {611      id,612      sas,613      displayOnly: false,614      peerFingerprint: pairingSessionFingerprint(handshakeHash),615    });616  });617}618619function waitForSourceSasConfirmation(620  id: string,621  request: AdoptRequest,622  sas: string,623  handshakeHash: Uint8Array,624): Promise<boolean> {625  return new Promise<boolean>((resolve) => {626    const timer = setTimeout(() => {627      pendingSourceSasConfirmation.delete(id);628      resolve(false);629    }, SOURCE_WINDOW_MS);630    pendingSourceSasConfirmation.set(id, {631      resolve: (ok) => {632        clearTimeout(timer);633        resolve(ok);634      },635    });636    push("ADOPTION_SOURCE_SAS", {637      id,638      sas,639      receiverDeviceName: request.device_name,640      peerFingerprint: pairingSessionFingerprint(handshakeHash),641    });642  });643}644645/**646 * Opening Add Device in an unlocked extension is the source-side authorization.647 * Announce the SAS for comparison, then let the scanning device's explicit648 * confirmation be the single commit gate. grantAdoption still refuses to send649 * the root key until the receiver returns ADOPT_CONFIRM(true).650 */651function requestConsent(652  request: AdoptRequest,653  sas: string,654  id: string,655  handshakeHash: Uint8Array,656): Promise<boolean> {657  return waitForSourceSasConfirmation(id, request, sas, handshakeHash);658}659660function clearState(): void {661  activeSocket = null;662  inFlight = false;663  pendingSourceSasConfirmation.clear();664  pendingReceiverSasConfirmation.clear();665}666667function constantTimeEqual(a: string, b: string): boolean {668  if (a.length !== b.length) return false;669  let diff = 0;670  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);671  return diff === 0;672}673674function encodeJson(value: unknown): Uint8Array {675  return new TextEncoder().encode(JSON.stringify(value));676}677678function decodeJson<T>(value: Uint8Array): T {679  return JSON.parse(new TextDecoder().decode(value)) as T;680}681682/**683 * PB-26-213: an adopted device's secret bypasses the public registry into684 * the encrypted pair-secret record. The registry row stays secretless; when685 * the vault is somehow locked here the row still lands and the next unlock686 * cannot recover this secret — re-pair is the fail-closed path.687 */688async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {689  const secret = paired.rendezvousSecretHex;690  const pub = { ...paired };691  delete pub.rendezvousSecretHex;692  await upsertPairedDevice(pub);693  if (!isValidPairSecret(secret)) return;694  const masterKeyHex = getRootKeyHex();695  if (!masterKeyHex) return;696  const masterKey = hexToBytes(masterKeyHex);697  try {698    await setPairSecret(paired.deviceId, secret, masterKey);699  } finally {700    masterKey.fill(0);701  }702}703704function hexToBytes(hex: string): Uint8Array {705  const out = new Uint8Array(hex.length / 2);706  for (let i = 0; i < out.length; i++) {707    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);708  }709  return out;710}