OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / vrf / ecvrf.js

291 lines · javascript ref:
/**
 * ecvrf.js — ECVRF-EDWARDS25519-SHA512-TAI (RFC 9381)
 *
 * A Verifiable Random Function: the holder of a secret key produces, for any
 * input `alpha`, a deterministic pseudorandom output `beta` together with a
 * proof `pi`. Anyone holding the public key can verify that `beta` is the
 * unique correct output for `alpha` under that key — without learning the
 * secret, and without the prover being able to choose `beta`.
 *
 * This is the suite-0x03 "try-and-increment" (TAI) variant standardised in
 * RFC 9381. We pick TAI specifically because the RFC publishes byte-exact
 * test vectors for it (Appendix A.4), which we treat as the correctness gate
 * (see scripts/test-vrf-vectors.js). A subtly-wrong VRF is worse than none, so
 * every parameter below is pinned to the RFC and asserted against its vectors.
 *
 *   suite_string = 0x03
 *   curve        = edwards25519 (RFC 8032), cofactor = 8
 *   Hash         = SHA-512
 *   cLen         = 16   (challenge, octets)
 *   ptLen        = 32   (compressed point, octets)
 *   qLen         = 32   (scalar, octets)
 *   pi  length   = ptLen + cLen + qLen = 80 octets
 *   beta length  = 64 octets (full SHA-512 output)
 *
 * All integers are little-endian (consistent with RFC 8032 scalar encoding).
 *
 * Crypto backend: @noble/curves ed25519 Point/Fn (constant-time `multiply`
 * for secret scalars, variable-time `multiplyUnsafe` for public inputs) and
 * @noble/hashes SHA-512. We deliberately reuse the same vetted primitives the
 * poker Chaum-Pedersen module relies on rather than hand-rolling field math.
 */

import { ed25519 } from '@noble/curves/ed25519.js'
import { sha512 } from '@noble/hashes/sha2.js'

const Point = ed25519.Point
const Fn = Point.Fn // scalar field mod L (the prime group order)
const BASE = Point.BASE

// RFC 9381 §5.5 fixed parameters for ECVRF-EDWARDS25519-SHA512-TAI.
const SUITE = 0x03
const C_LEN = 16
const PT_LEN = 32
const Q_LEN = 32
export const PROOF_LEN = PT_LEN + C_LEN + Q_LEN // 80
export const BETA_LEN = 64
export const PUBKEY_LEN = 32
export const SECKEY_LEN = 32

// Domain separators (RFC 9381 §5.4).
const DS_ENCODE_FRONT = 0x01
const DS_ENCODE_BACK = 0x00
const DS_CHALLENGE_FRONT = 0x02
const DS_CHALLENGE_BACK = 0x00
const DS_PROOF_TO_HASH_FRONT = 0x03
const DS_PROOF_TO_HASH_BACK = 0x00

// The group order L must match the curve we think we're on. If a dependency
// bump ever changes this out from under us, fail loudly at import time rather
// than silently producing wrong proofs.
const ED25519_ORDER =
  7237005577332262213973186563042994240857116359379907606001950938285454250989n
if (Fn.ORDER !== ED25519_ORDER) {
  throw new Error('ecvrf: unexpected ed25519 scalar field order from @noble/curves')
}
if (Fn.isLE !== true) {
  throw new Error('ecvrf: expected little-endian scalar field')
}

// ── byte / bigint helpers ────────────────────────────────────────────────────

function leToBigInt (bytes) {
  let n = 0n
  for (let i = bytes.length - 1; i >= 0; i--) {
    n = (n << 8n) | BigInt(bytes[i])
  }
  return n
}

function bigIntToLE (n, len) {
  const out = new Uint8Array(len)
  let v = n
  for (let i = 0; i < len; i++) {
    out[i] = Number(v & 0xffn)
    v >>= 8n
  }
  if (v !== 0n) throw new Error('ecvrf: integer does not fit in requested length')
  return out
}

function concatBytes (...arrays) {
  let total = 0
  for (const a of arrays) total += a.length
  const out = new Uint8Array(total)
  let off = 0
  for (const a of arrays) { out.set(a, off); off += a.length }
  return out
}

function asBytes (input, name) {
  if (input instanceof Uint8Array) return input
  if (typeof input === 'string') {
    // hex string
    if (input.length % 2 !== 0 || /[^0-9a-fA-F]/.test(input)) {
      throw new Error(`ecvrf: ${name} string must be valid hex`)
    }
    const out = new Uint8Array(input.length / 2)
    for (let i = 0; i < out.length; i++) out[i] = parseInt(input.slice(i * 2, i * 2 + 2), 16)
    return out
  }
  if (input == null) throw new Error(`ecvrf: ${name} is required`)
  if (Array.isArray(input)) return Uint8Array.from(input)
  throw new Error(`ecvrf: ${name} must be a Uint8Array or hex string`)
}

export function toHex (bytes) {
  let s = ''
  for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
  return s
}

// ── key derivation (RFC 9381 §5.5 / RFC 8032 clamping) ───────────────────────

/**
 * Expand a 32-byte secret seed into the VRF secret scalar x, the RFC 8032
 * nonce prefix, and the corresponding public key Y = x*B.
 */
export function expandSeed (sk) {
  const seed = asBytes(sk, 'secret key')
  if (seed.length !== SECKEY_LEN) throw new Error('ecvrf: secret key must be 32 bytes')
  const h = sha512(seed)
  const scalarBytes = h.slice(0, 32)
  // RFC 8032 clamping.
  scalarBytes[0] &= 248
  scalarBytes[31] &= 127
  scalarBytes[31] |= 64
  // Reduce mod L: the clamped value can exceed L, but B has order L so the
  // public key (and every downstream scalar op) is unchanged by reduction.
  const x = Fn.create(leToBigInt(scalarBytes))
  const prefix = h.slice(32, 64)
  const Y = BASE.multiply(x)
  return { x, prefix, Y, pk: Y.toBytes() }
}

/** Derive just the 32-byte public key for a secret seed. */
export function publicKey (sk) {
  return expandSeed(sk).pk
}

// ── core algorithm pieces ────────────────────────────────────────────────────

/**
 * ECVRF_encode_to_curve_try_and_increment (RFC 9381 §5.4.1.1).
 * encode_to_curve_salt = the encoded public key (RFC 9381 §5.5).
 */
function encodeToCurveTAI (pk, alpha) {
  for (let ctr = 0; ctr <= 255; ctr++) {
    const hash = sha512(concatBytes(
      Uint8Array.of(SUITE),
      Uint8Array.of(DS_ENCODE_FRONT),
      pk,
      alpha,
      Uint8Array.of(ctr),
      Uint8Array.of(DS_ENCODE_BACK)
    ))
    let candidate
    try {
      // arbitrary_string_to_point: decode the leftmost 32 octets as a point.
      candidate = Point.fromBytes(hash.slice(0, 32))
    } catch {
      continue // not a valid point encoding → try next counter
    }
    // cofactor * H clears any small-order component, landing in the prime group.
    const H = candidate.clearCofactor()
    if (H.is0()) continue
    return { H, ctr }
  }
  throw new Error('ecvrf: encode_to_curve exhausted counter (unreachable)')
}

/** ECVRF_nonce_generation_RFC8032 (RFC 9381 §5.4.2.2). */
function nonceGeneration (prefix, hString) {
  const k = sha512(concatBytes(prefix, hString))
  return Fn.create(leToBigInt(k))
}

/** ECVRF_challenge_generation (RFC 9381 §5.4.3). Returns a bigint < 2^(8*cLen). */
function challengeGeneration (points) {
  const parts = [Uint8Array.of(SUITE), Uint8Array.of(DS_CHALLENGE_FRONT)]
  for (const P of points) parts.push(P.toBytes())
  parts.push(Uint8Array.of(DS_CHALLENGE_BACK))
  const cString = sha512(concatBytes(...parts))
  return leToBigInt(cString.slice(0, C_LEN))
}

/** ECVRF_decode_proof (RFC 9381 §5.4.4). Throws on malformed input. */
function decodeProof (pi) {
  const bytes = asBytes(pi, 'proof')
  if (bytes.length !== PROOF_LEN) throw new Error('ecvrf: proof must be 80 bytes')
  const Gamma = Point.fromBytes(bytes.slice(0, PT_LEN)) // throws if invalid
  const c = leToBigInt(bytes.slice(PT_LEN, PT_LEN + C_LEN))
  const s = leToBigInt(bytes.slice(PT_LEN + C_LEN, PROOF_LEN))
  if (s >= Fn.ORDER) throw new Error('ecvrf: proof scalar s out of range')
  return { Gamma, c, s }
}

// ── public API ───────────────────────────────────────────────────────────────

/**
 * ECVRF_prove (RFC 9381 §5.1). Returns the 80-byte proof pi.
 * @param {Uint8Array|string} sk    32-byte secret seed (or hex)
 * @param {Uint8Array|string} alpha input message (or hex)
 * @returns {Uint8Array} pi
 */
export function prove (sk, alpha) {
  const alphaBytes = asBytes(alpha ?? new Uint8Array(0), 'alpha')
  const { x, prefix, Y, pk } = expandSeed(sk)
  const { H } = encodeToCurveTAI(pk, alphaBytes)
  const hString = H.toBytes()
  const Gamma = H.multiply(x)
  const k = nonceGeneration(prefix, hString)
  const U = BASE.multiply(k) // k*B
  const V = H.multiply(k) // k*H
  const c = challengeGeneration([Y, H, Gamma, U, V])
  const s = Fn.add(k, Fn.mul(c, x)) // (k + c*x) mod L
  return concatBytes(Gamma.toBytes(), bigIntToLE(c, C_LEN), bigIntToLE(s, Q_LEN))
}

/**
 * ECVRF_proof_to_hash (RFC 9381 §5.2). Maps a valid proof to its 64-byte VRF
 * output beta. NOTE: this does NOT verify the proof — call verify() first if
 * the proof is from an untrusted source.
 * @param {Uint8Array|string} pi 80-byte proof
 * @returns {Uint8Array} beta (64 bytes)
 */
export function proofToHash (pi) {
  const { Gamma } = decodeProof(pi)
  return sha512(concatBytes(
    Uint8Array.of(SUITE),
    Uint8Array.of(DS_PROOF_TO_HASH_FRONT),
    Gamma.clearCofactor().toBytes(), // cofactor * Gamma
    Uint8Array.of(DS_PROOF_TO_HASH_BACK)
  ))
}

/**
 * ECVRF_verify (RFC 9381 §5.3). Returns true iff pi is a valid proof that the
 * holder of `pk` produced the VRF output for `alpha`.
 * @param {Uint8Array|string} pk    32-byte public key (or hex)
 * @param {Uint8Array|string} alpha input message (or hex)
 * @param {Uint8Array|string} pi    80-byte proof (or hex)
 * @returns {boolean}
 */
export function verify (pk, alpha, pi) {
  let Gamma, c, s, Y
  try {
    ({ Gamma, c, s } = decodeProof(pi))
    Y = Point.fromBytes(asBytes(pk, 'public key'))
  } catch {
    return false
  }
  const alphaBytes = asBytes(alpha ?? new Uint8Array(0), 'alpha')
  const pkBytes = Y.toBytes()
  const { H } = encodeToCurveTAI(pkBytes, alphaBytes)
  // U = s*B - c*Y ; V = s*H - c*Gamma. All scalars/points here are public, so
  // variable-time multiplyUnsafe is appropriate (and accepts a zero scalar).
  const U = BASE.multiplyUnsafe(s).subtract(Y.multiplyUnsafe(c))
  const V = H.multiplyUnsafe(s).subtract(Gamma.multiplyUnsafe(c))
  const cPrime = challengeGeneration([Y, H, Gamma, U, V])
  return c === cPrime
}

/**
 * Convenience: verify a proof and, if valid, return its VRF output beta.
 * @returns {{ valid: boolean, beta: Uint8Array|null }}
 */
export function verifyAndHash (pk, alpha, pi) {
  if (!verify(pk, alpha, pi)) return { valid: false, beta: null }
  return { valid: true, beta: proofToHash(pi) }
}

export const params = Object.freeze({
  suite: 'ECVRF-EDWARDS25519-SHA512-TAI',
  suiteByte: SUITE,
  cLen: C_LEN,
  ptLen: PT_LEN,
  qLen: Q_LEN,
  proofLen: PROOF_LEN,
  betaLen: BETA_LEN
})