noise-js.ts

21.2 KB · raw

1/**2 * Noise NK initiator for Android / Capacitor.3 * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s4 *5 * The Android client is a hand-port of this code and must derive byte-for-byte6 * the same handshake — interop holds by porting discipline, not by a shared7 * library. The earlier Rust `snow` host has been removed from this repo, so8 * there is no cross-implementation pin; `noise-js.test.ts` pins SELF-derived9 * vectors only (a regression guard against accidental changes on the JS side).10 *11 * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the12 * named pattern, so a stock Noise stack should interoperate.13 * The Android port must mirror these hash choices and transcript steps. Any14 * change to the hash, HKDF, or transcript here is a BREAKING wire change and15 * requires a coordinated Android release.16 */1718import { generateKeyPair, sharedKey } from "@stablelib/x25519";19import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";20import { BLAKE2s } from "@stablelib/blake2s";21import { SHA256 } from "@stablelib/sha256";22import { HMAC } from "@stablelib/hmac";2324const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";25const KK_PROTOCOL_NAME = "Noise_KK_25519_ChaChaPoly_BLAKE2s";2627function initProtocolHash(protocolName: string): Uint8Array {28  const nameBytes = new TextEncoder().encode(protocolName);29  if (nameBytes.length <= 32) {30    const h = new Uint8Array(32);31    h.set(nameBytes);32    return h;33  }34  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash35  // MUST use it too (Noise spec §5.2), not SHA-256.36  return new BLAKE2s(32).update(nameBytes).digest();37}3839export function initHash(): Uint8Array {40  return initProtocolHash(PROTOCOL_NAME);41}4243function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {44  const blake = new BLAKE2s(32);45  blake.update(h);46  blake.update(data);47  return blake.digest();48}4950export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {51  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.52  // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()53  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run54  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per55  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`56  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a57  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).58  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)59  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();60  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();61  return [k1, k2];62}6364export interface NoiseNKSession {65  encrypt(plaintext: Uint8Array): Uint8Array;66  decrypt(ciphertext: Uint8Array): Uint8Array;67  handshakeHash: Uint8Array;68}6970interface NoiseKeyPair {71  publicKey: Uint8Array;72  secretKey: Uint8Array;73}7475function requireX25519Key(key: Uint8Array, label: string): void {76  if (key.length !== 32) throw new Error(`${label} must be 32 bytes`);77}7879/**80 * Standard Noise KK initiator for reconnects.81 *82 * Pre-messages: -> s, <- s83 * Message 1:    -> e, es, ss84 * Message 2:    <- e, ee, se85 *86 * Both static keys are local inputs. There is no negotiation and no fallback87 * to NK: a mismatch hard-fails this handshake.88 */89export async function noiseKKInitiator(90  ourStaticPrivateKey: Uint8Array,91  ourStaticPublicKey: Uint8Array,92  responderStaticPublicKey: Uint8Array,93  send: (msg: Uint8Array) => Promise<void>,94  receive: () => Promise<Uint8Array>,95  ephemeral: NoiseKeyPair = generateKeyPair(),96): Promise<NoiseNKSession> {97  requireX25519Key(ourStaticPrivateKey, "initiator private key");98  requireX25519Key(ourStaticPublicKey, "initiator public key");99  requireX25519Key(responderStaticPublicKey, "responder public key");100  requireX25519Key(ephemeral.secretKey, "initiator ephemeral private key");101  requireX25519Key(ephemeral.publicKey, "initiator ephemeral public key");102103  let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);104  let ck: Uint8Array = h.slice();105  h = mixHash(h, ourStaticPublicKey);106  h = mixHash(h, responderStaticPublicKey);107108  // -> e, es, ss109  h = mixHash(h, ephemeral.publicKey);110  let keys = hkdf2(111    ck,112    sharedKey(ephemeral.secretKey, responderStaticPublicKey, true),113  );114  ck = keys[0];115  keys = hkdf2(116    ck,117    sharedKey(ourStaticPrivateKey, responderStaticPublicKey, true),118  );119  ck = keys[0];120  const encrypted1 = new ChaCha20Poly1305(keys[1]).seal(121    new Uint8Array(12),122    new Uint8Array(0),123    h,124  );125  h = mixHash(h, encrypted1);126  const msg1 = new Uint8Array(32 + encrypted1.length);127  msg1.set(ephemeral.publicKey, 0);128  msg1.set(encrypted1, 32);129  await send(msg1);130131  // <- e, ee, se132  const msg2 = await receive();133  if (msg2.length < 48) throw new Error("Noise KK: msg2 too short");134  const responderEphemeralPublic = msg2.slice(0, 32);135  const encrypted2 = msg2.slice(32);136  h = mixHash(h, responderEphemeralPublic);137  keys = hkdf2(138    ck,139    sharedKey(ephemeral.secretKey, responderEphemeralPublic, true),140  );141  ck = keys[0];142  keys = hkdf2(143    ck,144    sharedKey(ourStaticPrivateKey, responderEphemeralPublic, true),145  );146  ck = keys[0];147  const opened2 = new ChaCha20Poly1305(keys[1]).open(148    new Uint8Array(12),149    encrypted2,150    h,151  );152  if (!opened2) {153    throw new Error("Noise KK: msg2 auth failed — wrong peer key or MITM");154  }155  h = mixHash(h, encrypted2);156157  const [sendKey, receiveKey] = hkdf2(ck, new Uint8Array(0));158  return transportSession(sendKey, receiveKey, h, "KK");159}160161/**162 * Standard Noise KK responder. The expected initiator key must come from the163 * local paired-device registry before this function is called.164 */165export async function noiseKKResponder(166  ourStaticPrivateKey: Uint8Array,167  ourStaticPublicKey: Uint8Array,168  expectedInitiatorStaticPublicKey: Uint8Array,169  send: (msg: Uint8Array) => Promise<void>,170  receive: () => Promise<Uint8Array>,171  ephemeral: NoiseKeyPair = generateKeyPair(),172): Promise<NoiseNKSession> {173  requireX25519Key(ourStaticPrivateKey, "responder private key");174  requireX25519Key(ourStaticPublicKey, "responder public key");175  requireX25519Key(expectedInitiatorStaticPublicKey, "initiator public key");176  requireX25519Key(ephemeral.secretKey, "responder ephemeral private key");177  requireX25519Key(ephemeral.publicKey, "responder ephemeral public key");178179  let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);180  let ck: Uint8Array = h.slice();181  h = mixHash(h, expectedInitiatorStaticPublicKey);182  h = mixHash(h, ourStaticPublicKey);183184  // -> e, es, ss185  const msg1 = await receive();186  if (msg1.length < 48) throw new Error("Noise KK: msg1 too short");187  const initiatorEphemeralPublic = msg1.slice(0, 32);188  const encrypted1 = msg1.slice(32);189  h = mixHash(h, initiatorEphemeralPublic);190  let keys = hkdf2(191    ck,192    sharedKey(ourStaticPrivateKey, initiatorEphemeralPublic, true),193  );194  ck = keys[0];195  keys = hkdf2(196    ck,197    sharedKey(ourStaticPrivateKey, expectedInitiatorStaticPublicKey, true),198  );199  ck = keys[0];200  const opened1 = new ChaCha20Poly1305(keys[1]).open(201    new Uint8Array(12),202    encrypted1,203    h,204  );205  if (!opened1) {206    throw new Error("Noise KK: msg1 auth failed — wrong peer key or MITM");207  }208  h = mixHash(h, encrypted1);209210  // <- e, ee, se211  h = mixHash(h, ephemeral.publicKey);212  keys = hkdf2(213    ck,214    sharedKey(ephemeral.secretKey, initiatorEphemeralPublic, true),215  );216  ck = keys[0];217  keys = hkdf2(218    ck,219    sharedKey(ephemeral.secretKey, expectedInitiatorStaticPublicKey, true),220  );221  ck = keys[0];222  const encrypted2 = new ChaCha20Poly1305(keys[1]).seal(223    new Uint8Array(12),224    new Uint8Array(0),225    h,226  );227  h = mixHash(h, encrypted2);228  const msg2 = new Uint8Array(32 + encrypted2.length);229  msg2.set(ephemeral.publicKey, 0);230  msg2.set(encrypted2, 32);231  await send(msg2);232233  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));234  return transportSession(responderSendKey, initiatorSendKey, h, "KK");235}236237function transportSession(238  sendKey: Uint8Array,239  receiveKey: Uint8Array,240  handshakeHash: Uint8Array,241  pattern: "KK",242): NoiseNKSession {243  let sendNonce = 0n;244  let receiveNonce = 0n;245  const sendCipher = new ChaCha20Poly1305(sendKey);246  const receiveCipher = new ChaCha20Poly1305(receiveKey);247  const nonceBytes = (n: bigint) => {248    const nonce = new Uint8Array(12);249    new DataView(nonce.buffer).setBigUint64(4, n, true);250    return nonce;251  };252  return {253    encrypt(plaintext) {254      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));255    },256    decrypt(ciphertext) {257      const plaintext = receiveCipher.open(258        nonceBytes(receiveNonce++),259        ciphertext,260        new Uint8Array(0),261      );262      if (!plaintext) throw new Error(`Noise ${pattern}: decryption failed`);263      return plaintext;264    },265    handshakeHash: handshakeHash.slice(),266  };267}268269/**270 * Run the initiator side of the Noise NK handshake. The caller provides271 * `send` / `receive` for the two transport-side handshake messages and gets272 * back a session object that encrypts/decrypts subsequent transport frames.273 */274export async function noiseNKInitiator(275  responderStaticPublicKey: Uint8Array,276  send: (msg: Uint8Array) => Promise<void>,277  receive: () => Promise<Uint8Array>,278): Promise<NoiseNKSession> {279  let h: Uint8Array = initHash();280  let ck: Uint8Array = h.slice();281282  // NK pre-message: mix responder static public key.283  h = mixHash(h, responderStaticPublicKey);284285  const ephemeral = generateKeyPair();286287  // --- Message 1: e, es ---288  h = mixHash(h, ephemeral.publicKey);289  // PCH-02-009: reject all-zero DH outputs from low-order peer points before290  // they enter the Noise key schedule. Honest peers are unaffected.291  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);292  const [ck1, k1] = hkdf2(ck, es);293  ck = ck1;294295  const cipher1 = new ChaCha20Poly1305(k1);296  const nonce1 = new Uint8Array(12);297  const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);298  h = mixHash(h, encrypted1);299300  const msg1 = new Uint8Array(32 + encrypted1.length);301  msg1.set(ephemeral.publicKey, 0);302  msg1.set(encrypted1, 32);303  await send(msg1);304305  // --- Message 2: e, ee ---306  const msg2 = await receive();307  if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");308309  const remoteEphemeralPublic = msg2.slice(0, 32);310  const encrypted2 = msg2.slice(32);311312  h = mixHash(h, remoteEphemeralPublic);313  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);314  const [ck2, k2] = hkdf2(ck, ee);315  ck = ck2;316317  const cipher2 = new ChaCha20Poly1305(k2);318  const nonce2 = new Uint8Array(12);319  const decrypted2 = cipher2.open(nonce2, encrypted2, h);320  if (!decrypted2) {321    throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");322  }323  h = mixHash(h, encrypted2);324325  // --- Transport phase ---326  const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));327  const handshakeHash = h.slice();328329  let sendNonce = 0n;330  let recvNonce = 0n;331332  function nonceBytes(n: bigint): Uint8Array {333    const buf = new Uint8Array(12);334    new DataView(buf.buffer).setBigUint64(4, n, true);335    return buf;336  }337338  const sendCipher = new ChaCha20Poly1305(sendKey);339  const recvCipher = new ChaCha20Poly1305(recvKey);340341  return {342    encrypt(plaintext) {343      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));344    },345    decrypt(ciphertext) {346      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));347      if (!result) throw new Error("Noise NK: decryption failed");348      return result;349    },350    handshakeHash,351  };352}353354/**355 * Compute the post-handshake auth proof.356 *357 * proof = HMAC-SHA256(358 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared359 *   data  = "peach-sync-v1-initiator-auth"360 *        || initiator_public361 *        || responder_public362 *        || nonce_32363 * )364 *365 * Both sides pass the same DH peer key (their PEER's public key) and the same366 * initiator_public/responder_public ordering so the MAC matches. This helper367 * targets the initiator-side: `local_priv` is the initiator's private key,368 * `remote_pub` is the responder's public key.369 */370const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");371372export function computeAuthProof(373  initiatorPrivate: Uint8Array,374  initiatorPublic: Uint8Array,375  responderPublic: Uint8Array,376  nonce: Uint8Array,377  handshakeHash: Uint8Array,378): Uint8Array {379  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");380  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");381382  const dh = sharedKey(initiatorPrivate, responderPublic, true);383  const mac = new HMAC(SHA256, dh);384  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed385  // onto a different handshake. handshakeHash is prepended; both sides hold the386  // identical value and feed it in the same position.387  mac.update(handshakeHash);388  mac.update(AUTH_DOMAIN);389  mac.update(initiatorPublic);390  mac.update(responderPublic);391  mac.update(nonce);392  return mac.digest();393}394395interface AuthChallengeFrame {396  type: "CHALLENGE";397  nonce_hex: string;398  responder_public_key_hex: string;399}400401interface AuthProofFrame {402  type: "PROOF";403  initiator_public_key_hex: string;404  proof_hex: string;405}406407function bytesToHex(bytes: Uint8Array): string {408  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");409}410411function hexToBytes(hex: string): Uint8Array {412  if (hex.length % 2 !== 0) throw new Error("invalid hex");413  const bytes = new Uint8Array(hex.length / 2);414  for (let i = 0; i < hex.length; i += 2) {415    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);416  }417  return bytes;418}419420/**421 * Drive the initiator side of the post-handshake challenge/proof round. The422 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply423 * with a `Proof` frame. Both frames are encrypted via the post-handshake424 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).425 */426/**427 * Run the responder side of the Noise NK handshake. Symmetric with428 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two429 * handshake messages and gets back a session for transport-phase frames.430 *431 * Symmetric with the Android hand-port; no shared Rust implementation remains.432 * An Android-as-host pairing session can be driven by the browser initiator and433 * vice versa as long as both derive over BLAKE2s (PCH-01-002).434 */435export async function noiseNKResponder(436  ourStaticPrivateKey: Uint8Array,437  ourStaticPublicKey: Uint8Array,438  send: (msg: Uint8Array) => Promise<void>,439  receive: () => Promise<Uint8Array>,440): Promise<NoiseNKSession> {441  let h: Uint8Array = initHash();442  let ck: Uint8Array = h.slice();443444  // NK pre-message: mix our (responder) static public key. The initiator445  // pre-mixed the same key on its side; both transcripts must match.446  h = mixHash(h, ourStaticPublicKey);447448  // --- Message 1: e, es ---449  const msg1 = await receive();450  if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");451  const remoteEphemeralPublic = msg1.slice(0, 32);452  const encrypted1 = msg1.slice(32);453454  h = mixHash(h, remoteEphemeralPublic);455  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);456  const [ck1, k1] = hkdf2(ck, es);457  ck = ck1;458459  const cipher1 = new ChaCha20Poly1305(k1);460  const nonce1 = new Uint8Array(12);461  const decrypted1 = cipher1.open(nonce1, encrypted1, h);462  if (!decrypted1) {463    throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");464  }465  h = mixHash(h, encrypted1);466467  // --- Message 2: e, ee ---468  const ephemeral = generateKeyPair();469  h = mixHash(h, ephemeral.publicKey);470  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);471  const [ck2, k2] = hkdf2(ck, ee);472  ck = ck2;473474  const cipher2 = new ChaCha20Poly1305(k2);475  const nonce2 = new Uint8Array(12);476  const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);477  h = mixHash(h, encrypted2);478479  const msg2 = new Uint8Array(32 + encrypted2.length);480  msg2.set(ephemeral.publicKey, 0);481  msg2.set(encrypted2, 32);482  await send(msg2);483484  // --- Transport phase ---485  // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive,486  // so the responder swaps: k1 → recv, k2 → send.487  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));488  const handshakeHash = h.slice();489490  let sendNonce = 0n;491  let recvNonce = 0n;492493  function nonceBytes(n: bigint): Uint8Array {494    const buf = new Uint8Array(12);495    new DataView(buf.buffer).setBigUint64(4, n, true);496    return buf;497  }498499  const sendCipher = new ChaCha20Poly1305(responderSendKey);500  const recvCipher = new ChaCha20Poly1305(initiatorSendKey);501502  return {503    encrypt(plaintext) {504      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));505    },506    decrypt(ciphertext) {507      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));508      if (!result) throw new Error("Noise NK: decryption failed");509      return result;510    },511    handshakeHash,512  };513}514515/**516 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`517 * goes through this so a same-LAN attacker can't time the HMAC compare.518 */519function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {520  if (a.length !== b.length) return false;521  let diff = 0;522  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;523  return diff === 0;524}525526/**527 * Drive the responder side of the post-handshake challenge/proof round. We528 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and529 * verify. Returns the initiator's authenticated public key on success.530 *531 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same532 * domain-separated transcript both sides feed into HMAC-SHA256.533 */534export async function performResponderAuth(535  session: NoiseNKSession,536  ourPrivateKey: Uint8Array,537  ourPublicKey: Uint8Array,538  sendEncryptedJson: (data: Uint8Array) => Promise<void>,539  receiveEncryptedJson: () => Promise<Uint8Array>,540): Promise<{ initiatorPublicKey: Uint8Array }> {541  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.542543  const nonce = new Uint8Array(32);544  crypto.getRandomValues(nonce);545546  const challenge: AuthChallengeFrame = {547    type: "CHALLENGE",548    nonce_hex: bytesToHex(nonce),549    responder_public_key_hex: bytesToHex(ourPublicKey),550  };551  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));552553  const proofBytes = await receiveEncryptedJson();554  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;555  if (proofFrame.type !== "PROOF") {556    throw new Error("expected PROOF frame");557  }558559  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);560  const claimedProof = hexToBytes(proofFrame.proof_hex);561  if (initiatorPublicKey.length !== 32) {562    throw new Error("initiator public key must be 32 bytes");563  }564  if (claimedProof.length !== 32) {565    throw new Error("proof must be 32 bytes");566  }567568  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)569  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);570  const mac = new HMAC(SHA256, dh);571  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the572  // existing domain/keys/nonce transcript. Same value, same position on both ends.573  mac.update(session.handshakeHash);574  mac.update(AUTH_DOMAIN);575  mac.update(initiatorPublicKey);576  mac.update(ourPublicKey);577  mac.update(nonce);578  const expected = mac.digest();579580  if (!constantTimeEq(expected, claimedProof)) {581    throw new Error("auth proof did not verify");582  }583  return { initiatorPublicKey };584}585586export async function performInitiatorAuth(587  session: NoiseNKSession,588  ourPrivateKey: Uint8Array,589  ourPublicKey: Uint8Array,590  responderPublicKey: Uint8Array,591  sendEncryptedJson: (data: Uint8Array) => Promise<void>,592  receiveEncryptedJson: () => Promise<Uint8Array>,593): Promise<void> {594  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding595  // it to this exact session (was previously bound via the DH key only).596597  const challengeBytes = await receiveEncryptedJson();598  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;599  if (challenge.type !== "CHALLENGE") {600    throw new Error("expected CHALLENGE frame");601  }602  const nonce = hexToBytes(challenge.nonce_hex);603  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);604  // Defence against an off-key challenge: the responder's claimed key MUST be605  // the one we Noise-NK-handshook against, otherwise an active MITM that606  // managed to substitute a different key is rerouting the auth round.607  if (608    responderKeyFromChallenge.length !== responderPublicKey.length ||609    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])610  ) {611    throw new Error("responder key in challenge does not match Noise handshake");612  }613614  const proof = computeAuthProof(615    ourPrivateKey,616    ourPublicKey,617    responderPublicKey,618    nonce,619    session.handshakeHash,620  );621  const proofFrame: AuthProofFrame = {622    type: "PROOF",623    initiator_public_key_hex: bytesToHex(ourPublicKey),624    proof_hex: bytesToHex(proof),625  };626  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));627}628629export { bytesToHex, hexToBytes };