OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / incentive / payment / blind-mint.js

170 lines · javascript ref:
// blind-mint.js — Cashu NUT-00 BDHKE blind-signature mint (secp256k1).
//
// Chaumian blind signatures over secp256k1, implemented to the Cashu NUT-00
// spec so the tokens this relay mints ARE Cashu tokens (see cashu.js for the
// NUT-01/02 keyset + token wire formats). This is the unlinkable upgrade for
// the bearer-voucher seam in incentive/lease/index.js: the payer blinds their
// own secret, the mint only ever sees a blinded point at issue time and the
// cleartext secret at redeem time, so it cannot link the two.
//
// Crypto is composed from the audited @noble/curves secp256k1 (field/point
// arithmetic) — we only implement the NUT-00 composition on top, which is
// validated against the official NUT-00 test vectors in the test suite
// (hash_to_curve x3, blinded message x2, blind signature x2). secp256k1 is a
// prime-order group, so unlike a naive ed25519 build there are no cofactor /
// small-subgroup pitfalls; Point.fromBytes additionally rejects off-curve /
// identity points.
//
// Protocol (G = secp256k1 generator, k = mint secret scalar, K = k*G):
//   1. payer:  Y = hash_to_curve(x)         (x = secret bytes)
//              B_ = Y + r*G                  (r = random blinding scalar)
//   2. mint:   C_ = k*B_                     (blind signature; mint never sees x)
//   3. payer:  C  = C_ - r*K = k*Y           (unblind) -> token = (x, C)
//   4. mint:   accept iff k*hash_to_curve(x) == C, and x unspent (double-spend
//              guard lives in the caller).

import { secp256k1 } from '@noble/curves/secp256k1.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { randomBytes, hexToBytes, bytesToHex, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'

const Point = secp256k1.Point
const G = Point.BASE
// secp256k1 group order (public curve constant).
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n
const DOMAIN_SEPARATOR = utf8ToBytes('Secp256k1_HashToCurve_Cashu_')

const bytesToScalar = (b) => BigInt('0x' + bytesToHex(b)) % N
const scalarToHex = (n) => (((n % N) + N) % N).toString(16).padStart(64, '0')

function randomScalar () {
  for (;;) {
    const s = bytesToScalar(randomBytes(32))
    if (s !== 0n) return s
  }
}

/**
 * Cashu NUT-00 hash_to_curve. Deterministically maps a message to a secp256k1
 * point: Y = PublicKey('02' || SHA256(SHA256(DOMAIN||x) || counterLE32)),
 * incrementing the uint32 LE counter until the 33-byte compressed encoding is
 * a valid point.
 * @param {Uint8Array|string} msg  raw bytes, or a hex string (decoded to bytes)
 * @returns {Point}
 */
export function hashToCurve (msg) {
  const m = typeof msg === 'string' ? hexToBytes(msg) : msg
  const msgHash = sha256(concatBytes(DOMAIN_SEPARATOR, m))
  const counter = new Uint8Array(4)
  const dv = new DataView(counter.buffer)
  for (let i = 0; i < 0x10000; i++) {
    dv.setUint32(0, i, true) // little-endian
    try {
      return Point.fromBytes(concatBytes(Uint8Array.of(0x02), sha256(concatBytes(msgHash, counter))))
    } catch (_) { /* not a valid x-coordinate; try next counter */ }
  }
  throw new Error('hashToCurve: no valid point within 2^16 counters')
}

// ─── Payer side (client) ─────────────────────────────────────────────

/** Fresh 32-byte token secret, hex-encoded (the Proof's `secret` field). */
export function newSecret () {
  return bytesToHex(randomBytes(32))
}

/**
 * Blind a secret. Returns { blinded: hex(B_), blindingFactor: hex(r) }.
 * @param {string} secret  hex secret
 */
export function blind (secret) {
  const Y = hashToCurve(secret)
  const r = randomScalar()
  const B_ = Y.add(G.multiply(r))
  return { blinded: B_.toHex(true), blindingFactor: scalarToHex(r) }
}

/**
 * Unblind a mint's blind signature into the final token point C.
 * @param {string} blindSignatureHex  hex(C_)
 * @param {string} blindingFactorHex  hex(r)
 * @param {string} mintPubkeyHex      hex(K)
 * @returns {string} hex(C)
 */
export function unblind (blindSignatureHex, blindingFactorHex, mintPubkeyHex) {
  const C_ = Point.fromBytes(hexToBytes(blindSignatureHex))
  const r = BigInt('0x' + blindingFactorHex) % N
  const K = Point.fromBytes(hexToBytes(mintPubkeyHex))
  const C = C_.add(K.multiply(r).negate())
  return C.toHex(true)
}

// ─── Mint side (relay) ───────────────────────────────────────────────

export class BlindMint {
  /**
   * @param {object} opts
   * @param {object} [opts.keyPair]  relay keyPair (mint scalar derived from its secretKey)
   * @param {string} [opts.secret]   explicit mint scalar as 64-hex (overrides keyPair)
   * @param {string} [opts.domain]   derivation domain separator
   */
  constructor (opts = {}) {
    if (opts.secret) {
      this._k = BigInt('0x' + opts.secret) % N
      if (this._k === 0n) throw new Error('BlindMint: secret reduces to zero')
    } else if (opts.keyPair && opts.keyPair.secretKey) {
      this._k = this._deriveScalar(opts.keyPair.secretKey, opts.domain || 'hiverelay-cashu-mint-v1')
    } else {
      throw new Error('BlindMint: keyPair or secret required')
    }
    this._K = G.multiply(this._k)
  }

  _deriveScalar (secretKey, domain) {
    const base = concatBytes(Uint8Array.from(secretKey), utf8ToBytes(domain))
    // Astronomically unlikely to be zero; rehash with a counter if it is.
    for (let i = 0; i < 256; i++) {
      const s = bytesToScalar(sha256(concatBytes(base, Uint8Array.of(i))))
      if (s !== 0n) return s
    }
    throw new Error('BlindMint: could not derive a non-zero scalar')
  }

  /** Mint public key K = k*G, compressed hex (the NUT-01 key for its amount). */
  get publicKey () {
    return this._K.toHex(true)
  }

  /**
   * Blind-sign a payer's blinded message B_. Rejects anything that isn't a
   * valid curve point (Point.fromBytes throws on off-curve/identity input).
   * @param {string} blindedHex  hex(B_)
   * @returns {string} hex(C_)
   */
  blindSign (blindedHex) {
    let B_
    try {
      B_ = Point.fromBytes(hexToBytes(blindedHex))
    } catch (_) {
      throw new Error('BLIND_INVALID_POINT: blinded message is not a valid secp256k1 point')
    }
    return B_.multiply(this._k).toHex(true)
  }

  /**
   * Verify a redeemed token (secret, C): C must equal k*hash_to_curve(secret).
   * Double-spend tracking is the caller's responsibility.
   * @param {string} secret  hex secret
   * @param {string} cHex    hex(C)
   * @returns {boolean}
   */
  verifyToken (secret, cHex) {
    let C
    try {
      C = Point.fromBytes(hexToBytes(cHex))
    } catch (_) {
      return false
    }
    return hashToCurve(secret).multiply(this._k).equals(C)
  }
}