OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / poker / crypto / chaum-pedersen.js

367 lines · javascript ref:
/**
 * Chaum-Pedersen discrete-log equality proof for threshold ElGamal share
 * validation, over the ed25519 curve.
 *
 * ─── Why this exists ────────────────────────────────────────────────────────
 *
 * The poker substrate registers application-level dispute types with the
 * arbitration service (see arbitration-service.js: `poker/invalid-share`).
 * To resolve those disputes an arbitrator must independently re-check the
 * cryptographic claim. This module provides the math for one of those
 * checks — "the published decryption share D = C1 ^ x where x is the same
 * secret as in the threshold public key Y = G ^ x" — without revealing x.
 *
 * Mathematically:
 *
 *   Setup:
 *     - G: a fixed generator of the ed25519 group
 *     - Threshold public key Y = G^x, where x is held in shares by N players
 *     - Ciphertext part C1 (the "r" component of an ElGamal pair (C1, C2))
 *
 *   Claim:
 *     - Player publishes D = C1^x and a proof showing log_G(Y) == log_C1(D)
 *
 *   Sigma protocol (Fiat-Shamir):
 *     1. Prover picks random k. Computes A = G^k, B = C1^k.
 *     2. Challenge e = H(G || Y || C1 || D || A || B).
 *     3. Response z = k + e*x  (mod the ed25519 group order ℓ).
 *
 *   Verification:
 *     - e' = H(G || Y || C1 || D || A || B)    (recompute, must equal e)
 *     - G^z  == A + Y^e        (point addition; non-multiplicative notation)
 *     - C1^z == B + D^e
 *
 * If both equations hold, the proof attests to validity of the share.
 *
 * ─── Status ─────────────────────────────────────────────────────────────────
 *
 * **Reference implementation.** Self-consistent — prover and verifier here
 * agree on byte ordering, hash domain, and curve choice — but NOT audited
 * for production stake. Real-money deployments should:
 *   1. Cross-verify against an independently-implemented prover for the
 *      same scheme (RFC 9497, libsignal, noble-curves, ...).
 *   2. Consider a curve with a prime-order group (ristretto255) if the
 *      threshold key generation depends on subgroup structure — ed25519's
 *      cofactor of 8 can be an issue for some DKG protocols.
 *
 * Operators who have an audited verifier for their actual production
 * scheme should register it via `arbitration.setAppEvidenceVerifier(
 * 'poker/invalid-share', fn)` and ignore this reference impl.
 *
 * ─── Byte ordering ─────────────────────────────────────────────────────────
 *
 * Sodium uses little-endian for ed25519 scalars. We follow the same
 * convention everywhere in this module. Points are 32 bytes in the standard
 * compressed Edwards encoding.
 */

import sodium from 'sodium-universal'
import b4a from 'b4a'
// noble-curves provides a best-effort constant-time scalar field for
// ed25519 (the scalar field has prime order ℓ, the group order). We use it
// ONLY for the prover's `e * x mod ℓ` step, where x is a long-term secret
// and timing leaks matter. Noble's `Fn.mul` is hardened against operand-
// dependent execution time — it doesn't reach the rigour of a native
// constant-time impl in C, but it's the best portable JS path available
// and is what every modern audited JS crypto stack ships against.
import { ed25519 as nobleEd25519 } from '@noble/curves/ed25519.js'

// ed25519 group order ℓ = 2^252 + 27742317777372353535851937790883648493.
// Sourced from RFC 8032 §5.1. Kept for documentation; the scalar arithmetic
// itself runs through noble's `ed25519.Point.Fn` which encodes the same
// constant. We assert at module load that the two agree, so any future
// noble curve-parameter change can't silently desync this module.
const ED25519_ORDER = 7237005577332262213973186563042994240857116359379907606001950938285454250989n
if (nobleEd25519.Point.Fn.ORDER !== ED25519_ORDER) {
  throw new Error('chaum-pedersen: noble ed25519 scalar field order does not match RFC 8032 ℓ')
}
const NOBLE_FN = nobleEd25519.Point.Fn

// Domain separator for the Fiat-Shamir hash. Any change to this string
// rotates all prior proofs out of validity, so DO NOT change it without a
// version bump in the evidence shape — proofs in the wild would silently
// stop verifying.
const FS_DOMAIN = 'hiverelay/poker/chaum-pedersen/v1'

const POINT_BYTES = 32
const SCALAR_BYTES = 32
const HASH_BYTES = 64 // we hash into 64 bytes then reduce mod ℓ

// ─── Public API ─────────────────────────────────────────────────────────────

/**
 * Verify a Chaum-Pedersen proof of share equality.
 *
 * @param {object} args
 * @param {Buffer} args.G   Generator (32 bytes). Default: ed25519 base point.
 * @param {Buffer} args.Y   Threshold public key (32 bytes).
 * @param {Buffer} args.C1  Ciphertext "r" component (32 bytes).
 * @param {Buffer} args.D   Published decryption share (32 bytes).
 * @param {Buffer} args.A   Proof commitment A = G^k (32 bytes).
 * @param {Buffer} args.B   Proof commitment B = C1^k (32 bytes).
 * @param {Buffer} args.z   Proof response z = k + e*x (32 bytes, scalar).
 * @returns {{ valid: boolean, reason?: string }}
 */
export function verifyShareEquality (args) {
  const G = args.G || baseG()
  const { Y, C1, D, A, B, z } = args

  // Shape checks — return reason strings rather than throwing so the
  // arbitration verifier can map them to 'inconclusive' verdicts cleanly.
  for (const [name, val] of [['Y', Y], ['C1', C1], ['D', D], ['A', A], ['B', B], ['G', G]]) {
    if (!val || val.byteLength !== POINT_BYTES) return { valid: false, reason: 'bad-point:' + name }
  }
  if (!z || z.byteLength !== SCALAR_BYTES) return { valid: false, reason: 'bad-scalar:z' }

  // Subgroup-validity checks — reject mixed-order or low-order points.
  //
  // ed25519's cofactor is 8: a curve point can be on the Edwards curve but
  // sit outside the prime-order subgroup. Without this check, an attacker
  // who controls any of Y, C1, D, A, or B can plant a mixed-order point
  // and have eq1/eq2 close for a proof that's only valid mod 8 of the
  // group order — the verifier returns valid=true for a claim that doesn't
  // attest what the dispute protocol thinks it does.
  //
  // `crypto_core_ed25519_is_valid_point` verifies: on the curve, canonical
  // form, in the main subgroup, and not low-order. That's exactly the
  // closure we need. We run it on every externally-sourced point;
  // G is internal (defaults to ed25519 base point) so we trust it.
  //
  // CVE-2025-69277 caveat: pre-fix libsodium had an incomplete check in
  // this function itself. Operators must run sodium-universal built
  // against libsodium >= commit ad3004e. This module assumes that; if
  // your deployment ships an older libsodium, this check still rejects
  // the common attack vectors but the fundamental defense is operator
  // dependency hygiene.
  for (const [name, val] of [['Y', Y], ['C1', C1], ['D', D], ['A', A], ['B', B]]) {
    if (!sodium.crypto_core_ed25519_is_valid_point(val)) {
      return { valid: false, reason: 'invalid-point:' + name }
    }
  }

  // Recompute challenge e' = H(G || Y || C1 || D || A || B), reduce mod ℓ.
  const e = fsChallenge(G, Y, C1, D, A, B)

  // Check G^z == A + Y^e.
  let lhs1, rhs1
  try {
    lhs1 = pointMulBase(z)
    const Ye = pointMul(e, Y)
    rhs1 = pointAdd(A, Ye)
  } catch (err) {
    return { valid: false, reason: 'point-op-failed:eq1:' + err.message }
  }
  if (!b4a.equals(lhs1, rhs1)) return { valid: false, reason: 'eq1-mismatch' }

  // Check C1^z == B + D^e.
  let lhs2, rhs2
  try {
    lhs2 = pointMul(z, C1)
    const De = pointMul(e, D)
    rhs2 = pointAdd(B, De)
  } catch (err) {
    return { valid: false, reason: 'point-op-failed:eq2:' + err.message }
  }
  if (!b4a.equals(lhs2, rhs2)) return { valid: false, reason: 'eq2-mismatch' }

  return { valid: true }
}

/**
 * Produce a Chaum-Pedersen proof for known x with Y = G^x and D = C1^x.
 * Provided for testing and for client SDKs — the relay never needs to
 * prove, only verify.
 *
 * The randomness k is sampled from sodium's CSPRNG via scalar_random,
 * so reuse-safety is on the caller side only if they re-seed deterministic
 * RNGs (don't).
 *
 * Timing posture (PROVER): the `e * x mod ℓ` step delegates to
 * noble-curves' best-effort constant-time scalar field op — see
 * scalarMulMod for the history. The VERIFIER side has no secret in scope
 * and is safe to deploy as-is.
 *
 * Caveat for real-money deployments: best-effort constant-time in pure JS
 * is materially better than raw BigInt but does not reach the rigour of a
 * native C impl. Production lines should either (a) confine proving to a
 * non-network-facing process anyway as defence in depth, or (b) swap in a
 * native constant-time primitive (e.g. sodium-native exposing
 * crypto_core_ed25519_scalar_mul). See arbitration.setAppEvidenceVerifier
 * for the swap-in hook on the verifier side and the README for the prover
 * side wiring guidance.
 *
 * @param {object} args
 * @param {Buffer} args.x   Secret scalar (32 bytes, little-endian).
 * @param {Buffer} args.Y   Threshold public key (32 bytes).
 * @param {Buffer} args.C1  Ciphertext "r" component (32 bytes).
 * @param {Buffer} args.D   Decryption share D = C1^x (32 bytes).
 * @param {Buffer} [args.G] Generator. Default ed25519 base point.
 * @returns {{ A: Buffer, B: Buffer, z: Buffer }}
 */
export function proveShareEquality (args) {
  const { x, Y, C1, D } = args
  const G = args.G || baseG()
  if (!x || x.byteLength !== SCALAR_BYTES) throw new Error('proveShareEquality: bad x')

  // Subgroup-validity checks on every externally-sourced point. Same
  // rationale as verifyShareEquality — without these, a malicious or
  // buggy caller can construct a "proof" against a mixed-order Y/C1/D
  // that's structurally valid but cryptographically meaningless. We
  // catch that here rather than producing nonsense outputs.
  for (const [name, val] of [['Y', Y], ['C1', C1], ['D', D]]) {
    if (!val || val.byteLength !== POINT_BYTES) {
      throw new Error('proveShareEquality: bad ' + name + ' length')
    }
    if (!sodium.crypto_core_ed25519_is_valid_point(val)) {
      throw new Error('proveShareEquality: invalid point ' + name + ' (off-curve or mixed-order)')
    }
  }

  // Random nonce k.
  const k = b4a.alloc(SCALAR_BYTES)
  sodium.crypto_core_ed25519_scalar_random(k)

  // Commitments A = G^k, B = C1^k.
  const A = pointMulBase(k)
  const B = pointMul(k, C1)

  // Challenge e = H(G || Y || C1 || D || A || B) mod ℓ.
  const e = fsChallenge(G, Y, C1, D, A, B)

  // Response z = k + e*x mod ℓ.
  // Uses noble-curves' constant-time scalar-field multiplication (Fn.mul)
  // instead of native JS BigInt — see scalarMulMod comment block for the
  // timing-leak history that motivated this swap.
  const ex = scalarMulMod(e, x)
  const z = b4a.alloc(SCALAR_BYTES)
  sodium.crypto_core_ed25519_scalar_add(z, k, ex)
  return { A, B, z }
}

/**
 * Convenience for callers without a precomputed threshold key — derive
 * Y = G^x from a secret scalar. Used by tests and example flows.
 */
export function publicFromSecret (x) {
  if (!x || x.byteLength !== SCALAR_BYTES) throw new Error('publicFromSecret: bad x')
  return pointMulBase(x)
}

/**
 * Compute D = C1^x for a published ciphertext C1 and secret x. Used by
 * tests and example flows — the relay never knows x.
 */
export function shareFor (x, C1) {
  return pointMul(x, C1)
}

/**
 * Public access to the canonical ed25519 base point as a 32-byte buffer.
 * Returned each call as a defensive copy so callers can mutate freely.
 */
export function baseG () {
  // Derive once and cache.
  if (!_cachedG) {
    const one = b4a.alloc(SCALAR_BYTES)
    one[0] = 1
    _cachedG = b4a.alloc(POINT_BYTES)
    sodium.crypto_scalarmult_ed25519_base_noclamp(_cachedG, one)
  }
  return b4a.from(_cachedG)
}
let _cachedG = null

// ─── Internal helpers ───────────────────────────────────────────────────────

/**
 * Variable-base scalar multiplication on ed25519: out = scalar * point.
 * Uses the no-clamp variant because we're doing generic group operations,
 * not Ed25519 signatures (which clamp the scalar).
 */
function pointMul (scalar, point) {
  const out = b4a.alloc(POINT_BYTES)
  sodium.crypto_scalarmult_ed25519_noclamp(out, scalar, point)
  return out
}

/**
 * Base-point scalar multiplication: out = scalar * G.
 */
function pointMulBase (scalar) {
  const out = b4a.alloc(POINT_BYTES)
  sodium.crypto_scalarmult_ed25519_base_noclamp(out, scalar)
  return out
}

/**
 * Point addition on the Edwards curve.
 */
function pointAdd (p, q) {
  const out = b4a.alloc(POINT_BYTES)
  sodium.crypto_core_ed25519_add(out, p, q)
  return out
}

/**
 * Fiat-Shamir challenge: a domain-separated BLAKE2b-512 of the proof
 * transcript, reduced mod ℓ. The domain string is included verbatim to
 * prevent cross-protocol replay (an A/B/z triple verifying under this
 * scheme can't pretend to be a proof from a different one that also
 * uses ed25519 point arithmetic).
 */
function fsChallenge (G, Y, C1, D, A, B) {
  const buf = b4a.alloc(HASH_BYTES)
  const domain = b4a.from(FS_DOMAIN, 'utf8')
  // crypto_generichash with input as a single concatenated buffer. The
  // transcript ordering matters; both prover and verifier must use the
  // same ordering. We pin it to (G, Y, C1, D, A, B) — same as the doc.
  const input = b4a.concat([domain, G, Y, C1, D, A, B])
  sodium.crypto_generichash(buf, input)
  // Reduce 64 → 32 mod ℓ for use as a scalar.
  const reduced = b4a.alloc(SCALAR_BYTES)
  sodium.crypto_core_ed25519_scalar_reduce(reduced, buf)
  return reduced
}

/**
 * Modular multiplication of two ed25519 scalars: (a * b) mod ℓ.
 *
 * Sodium-universal does not export `crypto_core_ed25519_scalar_mul` (the
 * primitive exists in libsodium but isn't surfaced through the bindings
 * we ship). We delegate to noble-curves' `ed25519.Point.Fn.mul`, which is
 * a best-effort constant-time implementation in pure JS.
 *
 * Timing history: an earlier version of this module computed the product
 * via raw JS BigInt. V8 BigInt multiplication is operand-value-dependent
 * in execution time, which makes the prover a timing oracle for the
 * long-term secret x. Noble's Fn.mul wraps the multiplication with
 * Montgomery-style reduction logic that doesn't branch on operand value
 * and is the path every modern audited JS crypto stack ships against. Not
 * the rigour of a native C constant-time impl, but the right portable JS
 * answer; for production-grade real-money use cases, swap to a native
 * binding (`sodium-native` with the scalar-mul primitive exposed, or a
 * WASM-built libsodium).
 *
 * Byte ordering: ed25519 scalars are little-endian (sodium convention,
 * also noble's Fn.isLE === true). Both sides agree, so fromBytes/toBytes
 * round-trip cleanly between formats.
 *
 * Inputs MUST be reduced mod ℓ already — both Fiat-Shamir challenges
 * (reduced via crypto_core_ed25519_scalar_reduce) and player secrets
 * (sampled via crypto_core_ed25519_scalar_random) satisfy this. Noble's
 * Fn.fromBytes will reject scalars outside [0, ℓ); failure throws and
 * propagates to the caller.
 */
function scalarMulMod (aBytes, bBytes) {
  const a = NOBLE_FN.fromBytes(aBytes)
  const b = NOBLE_FN.fromBytes(bBytes)
  const c = NOBLE_FN.mul(a, b)
  // Noble returns a 32-byte little-endian buffer (Fn.isLE === true); wrap
  // in b4a so the rest of the module sees the same buffer flavour it uses
  // everywhere else (Uint8Array view-compatibility is good enough for
  // sodium ops, but explicit b4a.from keeps types uniform).
  return b4a.from(NOBLE_FN.toBytes(c))
}

export { ED25519_ORDER, FS_DOMAIN, POINT_BYTES, SCALAR_BYTES }