OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / client / secret-sharing.js

477 lines · javascript ref:
/**
 * Publicly Verifiable Secret Sharing (PVSS) — client/dealer SDK surface.
 *
 * A Schoenmakers-style PVSS over secp256k1. This is the DEALER/SHAREHOLDER
 * side — the prover. It lives in the client SDK because the split and the
 * reconstruction happen wherever the secret lives (the publishing app, e.g.
 * Drop), never on a relay.
 *
 * BLIND BY CONSTRUCTION — the load-bearing invariant:
 *   - The dealer (this client) runs `split`. A relay NEVER sees the secret
 *     or runs the split.
 *   - Reconstruction runs client-side (`reconstruct`). A relay NEVER
 *     interpolates the secret.
 *   - Shares are ElGamal-encrypted to the *recipient/guardian* pubkeys,
 *     which MUST be disjoint from the custodying relays. A relay holds an
 *     opaque encrypted share it cannot open, yet can verify it against the
 *     public commitments without decrypting anything.
 *   - The publishable bundle (`result.public`) carries only commitments,
 *     encrypted shares and proofs — never the secret. `result.key` /
 *     `result.secretPoint` are returned to the dealer alone; do not publish
 *     them.
 *
 * BARE-SAFE. This ships inside Pear apps that run on Bare, not Node — so it
 * uses `sodium.randombytes_buf` + `b4a` and NEVER node `crypto`/`Buffer`.
 * (The dormant services build used `crypto.randomBytes`/`Buffer`, which throw
 * on Bare; that is precisely why this is a rewrite, not a copy.)
 *
 * The relay verifies these transcripts with the SAME challenge construction
 * via packages/core/core/pvss.js. The two are kept deliberately SEPARATE —
 * do NOT "DRY" them into a shared import: the client pins a published,
 * frozen p2p-hiverelay, so relay and client are independently-versioned
 * deployables that interoperate over the wire. Their byte-for-byte agreement
 * is the real interop contract, pinned by a cross-implementation test
 * (test/unit/custody-pvss.test.js), not by code sharing.
 *
 * Security note: PVSS confidentiality is COMPUTATIONAL (rests on DDH in
 * secp256k1), unlike the information-theoretic secrecy of plain Shamir. That
 * is the standard, expected tradeoff for public verifiability.
 */

import * as secp from '@noble/secp256k1'
import { sha256 } from '@noble/hashes/sha2.js'
import { hmac } from '@noble/hashes/hmac.js'
import sodium from 'sodium-universal'
import b4a from 'b4a'

// Wire noble hashes (required by @noble/secp256k1 v2+ for any ECDSA path).
// Guarded so it is a no-op when another module already wired the same module
// instance. PVSS itself only needs EC point arithmetic + BLAKE2b challenges.
if (!secp.hashes.sha256) {
  secp.hashes.sha256 = (...msgs) => sha256(secp.etc.concatBytes(...msgs.filter(m => m != null)))
  secp.hashes.hmacSha256 = (key, ...msgs) => hmac(sha256, key, secp.etc.concatBytes(...msgs.filter(m => m != null)))
}

// ─── Constants ──────────────────────────────────────────────────────

const G = secp.Point.BASE // secret generator: S = s*G
const N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n

// Independent commitment generator (nothing-up-my-sleeve). MUST match the
// seed used by packages/core/core/pvss.js or verification diverges.
const C_GEN = hashToCurveSimple('hiverelay-pvss-commit-generator-v1')

const KEY_DOMAIN = 'hiverelay-pvss-key-v1:'
const MAX_SHARES = 255

export const SCHEME = 'pvss-secp256k1-v1'

// ─── Field / curve helpers (Bare-safe) ──────────────────────────────

function modN (n) {
  return ((n % N) + N) % N
}

function randomScalar () {
  const buf = b4a.alloc(32)
  let s
  do {
    sodium.randombytes_buf(buf)
    s = modN(secp.etc.bytesToNumberBE(buf))
  } while (s === 0n)
  return s
}

function scalarToHex (s) {
  return s.toString(16).padStart(64, '0')
}

function hexToScalar (h) {
  return BigInt('0x' + h)
}

function pointToHex (P) {
  return P.toHex(true)
}

function hexToPoint (h) {
  return secp.Point.fromHex(h)
}

/** Modular inverse mod N via the iterative extended Euclidean algorithm. */
function invN (a) {
  const x = modN(a)
  if (x === 0n) throw new Error('SS_NOT_INVERTIBLE')
  let lm = 1n
  let hm = 0n
  let low = x
  let high = N
  while (low > 1n) {
    const ratio = high / low
    const nm = hm - lm * ratio
    const nw = high - low * ratio
    hm = lm
    high = low
    lm = nm
    low = nw
  }
  return modN(lm)
}

/** Hash arbitrary buffers/hex parts to a scalar mod N using BLAKE2b. */
function hashToScalar (...parts) {
  const input = b4a.concat(parts.map(p => typeof p === 'string' ? b4a.from(p, 'hex') : b4a.from(p)))
  const hash = b4a.alloc(32)
  sodium.crypto_generichash(hash, input)
  return modN(secp.etc.bytesToNumberBE(hash))
}

/** Find a valid secp256k1 point from a seed string (nothing-up-my-sleeve). */
function hashToCurveSimple (seed) {
  for (let counter = 0; counter < 256; counter++) {
    const input = b4a.from(seed + ':' + counter)
    const hash = b4a.alloc(32)
    sodium.crypto_generichash(hash, input)
    const compressed = b4a.concat([b4a.from([0x02]), hash])
    try {
      return secp.Point.fromHex(b4a.toString(compressed, 'hex'))
    } catch {
      continue
    }
  }
  throw new Error('hashToCurveSimple failed after 256 attempts')
}

/** Evaluate polynomial (coeffs[0] is the constant term) at x, mod N. */
function polyEval (coeffs, x) {
  let acc = 0n
  for (let j = coeffs.length - 1; j >= 0; j--) acc = modN(acc * x + coeffs[j])
  return acc
}

/**
 * Lagrange coefficients evaluated at x = 0 for the given index set.
 * λ_i = Π_{j≠i} (0 - x_j) / (x_i - x_j)   (mod N)
 * Returns a Map of indexBigInt → λ.
 */
function lagrangeAtZero (indices) {
  const out = new Map()
  for (const i of indices) {
    let num = 1n
    let den = 1n
    for (const j of indices) {
      if (j === i) continue
      num = modN(num * modN(-j))
      den = modN(den * modN(i - j))
    }
    out.set(i, modN(num * invN(den)))
  }
  return out
}

/** Derive a 32-byte symmetric key from a secret point. */
function keyFromPoint (S) {
  const buf = b4a.alloc(32)
  sodium.crypto_generichash(buf, b4a.from(KEY_DOMAIN + pointToHex(S)))
  return b4a.toString(buf, 'hex')
}

/** BLAKE2b over the concatenation of commitment hexes → 64-hex anchor. */
function commitmentRootOf (commitments) {
  const buf = b4a.alloc(32)
  sodium.crypto_generichash(buf, b4a.from(commitments.join('')))
  return b4a.toString(buf, 'hex')
}

// ─── DLEQ (discrete-log equality) ───────────────────────────────────

/**
 * Prove log_{base1}(A) == log_{base2}(B) == x, without revealing x.
 * Fiat-Shamir, challenge over (base1, A, base2, B, R1, R2).
 */
function proveDleq (base1, A, base2, B, x) {
  const k = randomScalar()
  const R1 = base1.multiply(k)
  const R2 = base2.multiply(k)
  const e = hashToScalar(
    pointToHex(base1), pointToHex(A),
    pointToHex(base2), pointToHex(B),
    pointToHex(R1), pointToHex(R2)
  )
  const s = modN(k - e * x)
  return { e: scalarToHex(e), s: scalarToHex(s) }
}

function verifyDleq (base1, A, base2, B, proof) {
  try {
    if (!proof || typeof proof.e !== 'string' || typeof proof.s !== 'string') return false
    const e = hexToScalar(proof.e)
    const s = hexToScalar(proof.s)
    const sOrN = s === 0n ? N : s
    const R1 = base1.multiply(sOrN).add(A.multiply(e))
    const R2 = base2.multiply(sOrN).add(B.multiply(e))
    const e2 = hashToScalar(
      pointToHex(base1), pointToHex(A),
      pointToHex(base2), pointToHex(B),
      pointToHex(R1), pointToHex(R2)
    )
    return e === e2
  } catch {
    return false
  }
}

/** Recompute X_i = Π_j C_j^(i^j) = C_GEN^p(i) from parsed commitment points. */
function commitmentShare (commitmentPoints, index) {
  const x = BigInt(index)
  let acc = null
  let xPow = 1n // x^0
  for (const Cj of commitmentPoints) {
    const term = Cj.multiply(modN(xPow) === 0n ? N : modN(xPow))
    acc = acc === null ? term : acc.add(term)
    xPow = modN(xPow * x)
  }
  return acc
}

function verifyShareConsistency (commitmentPoints, sh) {
  if (!sh || !Number.isInteger(sh.index) || sh.index < 1) return false
  try {
    const Xi = commitmentShare(commitmentPoints, sh.index)
    const yi = hexToPoint(sh.shareholder)
    const Yi = hexToPoint(sh.encryptedShare)
    return verifyDleq(C_GEN, Xi, yi, Yi, sh.proof)
  } catch {
    return false
  }
}

// ─── Public SDK surface ─────────────────────────────────────────────

/**
 * Generate a shareholder/recipient keypair (x, y = x*G).
 * @returns {Promise<{secretKey:string, publicKey:string}>}
 */
export async function keygen () {
  const x = randomScalar()
  return {
    secretKey: scalarToHex(x),
    publicKey: pointToHex(G.multiply(x))
  }
}

/**
 * Dealer step (CLIENT-SIDE ONLY). Split a secret t-of-n.
 *
 * @param {object} params
 * @param {number} params.threshold   minimum shares to reconstruct (>= 1)
 * @param {string[]} params.shareholders recipient pubkey hexes (length n >= t),
 *   MUST be disjoint from the custodying relays
 * @param {string} [params.secret]    optional 64-hex scalar; random if omitted
 * @returns {Promise<{threshold:number, secretPoint:string, key:string,
 *   public:{scheme:string, threshold:number, commitments:string[],
 *   commitmentRoot:string, encryptedShares:object[]}}>}
 *   secretPoint + key are DEALER-PRIVATE; only `public` is safe to publish.
 */
export async function split (params) {
  const { threshold, shareholders, secret } = params || {}
  if (!Number.isInteger(threshold) || threshold < 1) throw new Error('SS_INVALID_THRESHOLD')
  if (!Array.isArray(shareholders) || shareholders.length < threshold) {
    throw new Error('SS_THRESHOLD_EXCEEDS_SHAREHOLDERS')
  }
  if (shareholders.length > MAX_SHARES) throw new Error('SS_TOO_MANY_SHARES')

  const recipients = shareholders.map((pk, idx) => {
    try {
      return hexToPoint(pk)
    } catch {
      throw new Error('SS_BAD_SHAREHOLDER_PUBKEY:' + idx)
    }
  })

  // Polynomial p(x) = s + a_1 x + ... + a_{t-1} x^{t-1}, with p(0) = s.
  const s = secret ? modN(hexToScalar(secret)) : randomScalar()
  if (s === 0n) throw new Error('SS_INVALID_SECRET')
  const coeffs = [s]
  for (let j = 1; j < threshold; j++) coeffs.push(randomScalar())

  // Feldman commitments C_j = a_j * C_GEN.
  const commitmentPoints = coeffs.map(a => C_GEN.multiply(a))
  const commitments = commitmentPoints.map(pointToHex)

  const encryptedShares = recipients.map((yi, k) => {
    const index = k + 1 // x-coordinate, never 0
    const pi = polyEval(coeffs, BigInt(index))
    if (pi === 0n) throw new Error('SS_DEGENERATE_SHARE') // astronomically rare
    const Xi = C_GEN.multiply(pi) // public, == commitmentShare(commitments, index)
    const Yi = yi.multiply(pi) // encrypted share: only holder of xi can open
    const proof = proveDleq(C_GEN, Xi, yi, Yi, pi)
    return {
      index,
      shareholder: pointToHex(yi),
      encryptedShare: pointToHex(Yi),
      proof
    }
  })

  const S = G.multiply(s)
  return {
    threshold,
    secretPoint: pointToHex(S),
    key: keyFromPoint(S),
    public: {
      scheme: SCHEME,
      threshold,
      commitments,
      commitmentRoot: commitmentRootOf(commitments),
      encryptedShares
    }
  }
}

/**
 * Public verification (no secret keys, no decryption). Confirms each encrypted
 * share is consistent with the published Feldman commitments. Useful for a
 * dealer self-checking a bundle before publishing.
 *
 * @param {object} params
 * @param {string[]} params.commitments
 * @param {object[]} params.encryptedShares
 * @param {number} [params.index]  verify only this share index
 * @returns {Promise<{valid:boolean, verified:number, badIndices:number[]}>}
 */
export async function verifyShares (params) {
  const { commitments, encryptedShares, index } = params || {}
  if (!Array.isArray(commitments) || commitments.length < 1) throw new Error('SS_MISSING_COMMITMENTS')
  if (!Array.isArray(encryptedShares)) throw new Error('SS_MISSING_SHARES')

  let commitmentPoints
  try {
    commitmentPoints = commitments.map(hexToPoint)
  } catch {
    throw new Error('SS_BAD_COMMITMENT')
  }

  const subset = index === undefined
    ? encryptedShares
    : encryptedShares.filter(sh => sh && sh.index === index)
  if (index !== undefined && subset.length === 0) throw new Error('SS_INDEX_NOT_FOUND')

  const badIndices = []
  let verified = 0
  for (const sh of subset) {
    if (verifyShareConsistency(commitmentPoints, sh)) verified++
    else badIndices.push(sh && sh.index)
  }
  return { valid: badIndices.length === 0, verified, badIndices }
}

/**
 * Shareholder step (CLIENT-SIDE). Decrypt an encrypted share with the
 * shareholder secret key, producing the share value S_i = p(i)*G and a DLEQ
 * proof that the decryption is correct.
 *
 * @param {object} params
 * @param {{index:number, encryptedShare:string}} params.encryptedShare
 * @param {string} params.secretKey
 * @returns {Promise<{index:number, share:string, shareholder:string,
 *   encryptedShare:string, proof:object}>}
 */
export async function decryptShare (params) {
  const { encryptedShare, secretKey } = params || {}
  if (!encryptedShare || !secretKey) throw new Error('SS_MISSING_PARAMS')
  const x = modN(hexToScalar(secretKey))
  if (x === 0n) throw new Error('SS_BAD_SECRET_KEY')

  let Yi
  try {
    Yi = hexToPoint(encryptedShare.encryptedShare)
  } catch {
    throw new Error('SS_BAD_ENCRYPTED_SHARE')
  }
  const yi = G.multiply(x)
  const Si = Yi.multiply(invN(x)) // (1/x) * (p(i) * x * G) = p(i) * G
  // DLEQ: log_G(yi) == log_{Si}(Yi) == x  (Yi = x * Si)
  const proof = proveDleq(G, yi, Si, Yi, x)
  return {
    index: encryptedShare.index,
    share: pointToHex(Si),
    shareholder: pointToHex(yi),
    encryptedShare: pointToHex(Yi),
    proof
  }
}

/**
 * Public verification of a decrypted share (no secret key).
 * @param {{share:string, shareholder:string, encryptedShare:string, proof:object}} params
 * @returns {Promise<{valid:boolean}>}
 */
export async function verifyDecryptedShare (params) {
  const { share, shareholder, encryptedShare, proof } = params || {}
  if (!share || !shareholder || !encryptedShare) throw new Error('SS_MISSING_PARAMS')
  try {
    const Si = hexToPoint(share)
    const yi = hexToPoint(shareholder)
    const Yi = hexToPoint(encryptedShare)
    return { valid: verifyDleq(G, yi, Si, Yi, proof) }
  } catch {
    return { valid: false }
  }
}

/**
 * Reconstruct the secret (CLIENT-SIDE) from >= threshold decrypted shares via
 * Lagrange interpolation in the exponent. If a share carries proof material,
 * its decryption DLEQ is verified first and a bad share is rejected.
 *
 * @param {object} params
 * @param {object[]} params.shares  [{ index, share, shareholder?, encryptedShare?, proof? }]
 * @param {number} [params.threshold]
 * @returns {Promise<{secretPoint:string, key:string}>}
 */
export async function reconstruct (params) {
  const { shares, threshold } = params || {}
  if (!Array.isArray(shares) || shares.length < 1) throw new Error('SS_MISSING_SHARES')
  if (Number.isInteger(threshold) && shares.length < threshold) {
    throw new Error('SS_INSUFFICIENT_SHARES')
  }

  const seen = new Set()
  const points = []
  for (const sh of shares) {
    if (!sh || !Number.isInteger(sh.index) || sh.index < 1) throw new Error('SS_BAD_SHARE_INDEX')
    if (seen.has(sh.index)) throw new Error('SS_DUPLICATE_SHARE_INDEX')
    seen.add(sh.index)
    // If verification material is present, a bad share must not be used.
    if (sh.proof && sh.shareholder && sh.encryptedShare) {
      const ok = verifyDleq(G, hexToPoint(sh.shareholder), hexToPoint(sh.share), hexToPoint(sh.encryptedShare), sh.proof)
      if (!ok) throw new Error('SS_INVALID_SHARE_PROOF:' + sh.index)
    }
    points.push({ index: BigInt(sh.index), P: hexToPoint(sh.share) })
  }

  const lambdas = lagrangeAtZero(points.map(p => p.index))
  let S = null
  for (const { index, P } of points) {
    const lambda = lambdas.get(index)
    const term = P.multiply(lambda === 0n ? N : lambda)
    S = S === null ? term : S.add(term)
  }
  return {
    secretPoint: pointToHex(S),
    key: keyFromPoint(S)
  }
}

/**
 * Derive the symmetric key from a (reconstructed) secret point.
 * @param {{secretPoint:string}} params
 * @returns {Promise<{key:string}>}
 */
export async function deriveKey (params) {
  const { secretPoint } = params || {}
  if (!secretPoint) throw new Error('SS_MISSING_PARAMS')
  return { key: keyFromPoint(hexToPoint(secretPoint)) }
}