OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / test-poker-share-verifier.js

352 lines · javascript ref:
#!/usr/bin/env node

/**
 * test-poker-share-verifier.js
 *
 * Validates the Chaum-Pedersen primitive and its arbitration-service
 * adapter end-to-end:
 *
 *   1. Round-trip: a real proof generated by proveShareEquality verifies.
 *   2. Tampered A → eq1-mismatch.
 *   3. Tampered B → eq2-mismatch.
 *   4. Tampered z (off by 1) → eq1-mismatch.
 *   5. Wrong Y (proof for a different threshold key) → fails.
 *   6. Wrong D (share doesn't match x) → fails.
 *   7. Wrong C1 (ciphertext substituted) → eq2-mismatch.
 *   8. Arbitration wrapper: well-formed valid proof → claim-refuted.
 *   9. Arbitration wrapper: well-formed but invalid proof → claim-supported.
 *  10. Arbitration wrapper: malformed hex → inconclusive (not a slash).
 *  11. End-to-end through ArbitrationService.setAppEvidenceVerifier.
 */

import sodium from 'sodium-universal'
import b4a from 'b4a'
import {
  verifyShareEquality, proveShareEquality, publicFromSecret, shareFor, baseG,
  POINT_BYTES, SCALAR_BYTES
} from '../packages/services/builtin/poker/crypto/chaum-pedersen.js'
import { makeInvalidShareVerifier } from '../packages/services/builtin/poker/crypto/share-verifier.js'
import { ArbitrationService } from '../packages/services/builtin/arbitration-service.js'

let passed = 0
let failed = 0

function assert (condition, label) {
  if (condition) { console.log(`  PASS  ${label}`); passed++ }
  else { console.log(`  FAIL  ${label}`); failed++ }
}

function randomScalar () {
  const s = b4a.alloc(SCALAR_BYTES)
  sodium.crypto_core_ed25519_scalar_random(s)
  return s
}

// Build a "ciphertext" C1: a random point. In a real ElGamal C1 = G^r;
// here we just need any valid curve point that the verifier hasn't seen.
function randomPoint () {
  const r = randomScalar()
  const p = b4a.alloc(POINT_BYTES)
  sodium.crypto_scalarmult_ed25519_base_noclamp(p, r)
  return p
}

function flipBit (buf, bitIndex) {
  const out = b4a.from(buf)
  out[bitIndex >> 3] ^= (1 << (bitIndex & 7))
  return out
}

// ── 1. Round-trip ───────────────────────────────────────────────────────────
function testRoundTrip () {
  console.log('\n── 1. Honest prove + verify round-trip ──')
  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })
  const v = verifyShareEquality({ Y, C1, D, ...proof })
  assert(v.valid, 'honest proof verifies (' + (v.reason || 'ok') + ')')
}

// ── 2-7. Tamper matrix ──────────────────────────────────────────────────────
function testTamperMatrix () {
  console.log('\n── 2..7. Tamper matrix ──')
  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })

  // 2. Tamper A
  let v = verifyShareEquality({ Y, C1, D, A: flipBit(proof.A, 7), B: proof.B, z: proof.z })
  // A flipped → e' changes (since e depends on A) AND lhs1 ≠ A + Y^e.
  // Could fail at eq1 or with point-decode if the flipped byte makes it
  // invalid. Either way: not valid.
  assert(!v.valid, 'tampered A → invalid (' + v.reason + ')')

  // 3. Tamper B
  v = verifyShareEquality({ Y, C1, D, A: proof.A, B: flipBit(proof.B, 13), z: proof.z })
  assert(!v.valid, 'tampered B → invalid (' + v.reason + ')')

  // 4. Tamper z (flip lowest bit)
  v = verifyShareEquality({ Y, C1, D, A: proof.A, B: proof.B, z: flipBit(proof.z, 0) })
  assert(!v.valid, 'tampered z → invalid (' + v.reason + ')')

  // 5. Wrong Y (a different threshold key)
  const Y2 = publicFromSecret(randomScalar())
  v = verifyShareEquality({ Y: Y2, C1, D, ...proof })
  assert(!v.valid, 'wrong Y → invalid (' + v.reason + ')')

  // 6. Wrong D (share for a different x)
  const D2 = shareFor(randomScalar(), C1)
  v = verifyShareEquality({ Y, C1, D: D2, ...proof })
  assert(!v.valid, 'wrong D → invalid (' + v.reason + ')')

  // 7. Wrong C1 (different ciphertext)
  const C1b = randomPoint()
  v = verifyShareEquality({ Y, C1: C1b, D, ...proof })
  assert(!v.valid, 'wrong C1 → invalid (' + v.reason + ')')
}

// ── 8, 9, 10. Arbitration wrapper ───────────────────────────────────────────
function testArbitrationWrapper () {
  console.log('\n── 8..10. Arbitration evidence wrapper ──')
  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })
  const verify = makeInvalidShareVerifier()

  // 8. Well-formed valid proof → claim-refuted (the share is valid, so
  // the claimant's accusation of invalidity is refuted).
  const goodEvidence = {
    tableKey: 'a'.repeat(64),
    handId: 'h',
    cardIndex: 17,
    ciphertext: b4a.toString(C1, 'hex'),
    share: b4a.toString(D, 'hex'),
    witness: {
      Y: b4a.toString(Y, 'hex'),
      proof: {
        A: b4a.toString(proof.A, 'hex'),
        B: b4a.toString(proof.B, 'hex'),
        z: b4a.toString(proof.z, 'hex')
      }
    }
  }
  let r = verify(goodEvidence)
  assert(r.verdict === 'claim-refuted', 'valid proof → claim-refuted (' + r.reason + ')')

  // 9. Tampered proof → claim-supported (share is invalid → claim correct).
  const badEvidence = JSON.parse(JSON.stringify(goodEvidence))
  badEvidence.witness.proof.z = b4a.toString(flipBit(proof.z, 0), 'hex')
  r = verify(badEvidence)
  assert(r.verdict === 'claim-supported', 'tampered z → claim-supported (' + r.reason + ')')

  // 10. Malformed hex → inconclusive (don't slash on procedural errors).
  const broken = JSON.parse(JSON.stringify(goodEvidence))
  broken.witness.Y = 'not-hex'
  r = verify(broken)
  assert(r.verdict === 'inconclusive', 'malformed Y → inconclusive (' + r.reason + ')')

  // Also: missing witness.proof
  const noProof = JSON.parse(JSON.stringify(goodEvidence))
  delete noProof.witness.proof
  r = verify(noProof)
  assert(r.verdict === 'inconclusive', 'missing proof → inconclusive (' + r.reason + ')')

  // Also: wrong-length hex
  const shortZ = JSON.parse(JSON.stringify(goodEvidence))
  shortZ.witness.proof.z = 'aa'
  r = verify(shortZ)
  assert(r.verdict === 'inconclusive', 'short z → inconclusive (' + r.reason + ')')
}

// ── 11. End-to-end through ArbitrationService ──────────────────────────────
async function testEndToEnd () {
  console.log('\n── 11. End-to-end through ArbitrationService ──')
  const svc = new ArbitrationService()
  svc.node = { router: { pubsub: { publish () {} } } }
  svc.setAppEvidenceVerifier('poker/invalid-share', makeInvalidShareVerifier())

  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })

  // Submit an invalid-share dispute with a VALID proof attached. The
  // verifier will say "share is valid" → claim-refuted.
  const d = await svc.submit({
    type: 'poker/invalid-share',
    respondent: 'b'.repeat(64),
    claimant: 'c'.repeat(64),
    appEvidence: {
      tableKey: 'a'.repeat(64),
      handId: 'h',
      cardIndex: 4,
      ciphertext: b4a.toString(C1, 'hex'),
      share: b4a.toString(D, 'hex'),
      witness: {
        Y: b4a.toString(Y, 'hex'),
        proof: {
          A: b4a.toString(proof.A, 'hex'),
          B: b4a.toString(proof.B, 'hex'),
          z: b4a.toString(proof.z, 'hex')
        }
      }
    },
    penalty: 0
  }, { caller: 'local' })

  const e = await svc.evidence({ id: d.id })
  assert(e.appEvidence && e.appEvidence.verdict === 'claim-refuted',
    'end-to-end: valid proof → claim-refuted (' + (e.appEvidence ? e.appEvidence.reason : 'null') + ')')

  // Now submit with a tampered proof → claim-supported.
  const d2 = await svc.submit({
    type: 'poker/invalid-share',
    respondent: 'b'.repeat(64),
    claimant: 'c'.repeat(64),
    appEvidence: {
      tableKey: 'a'.repeat(64),
      handId: 'h',
      cardIndex: 4,
      ciphertext: b4a.toString(C1, 'hex'),
      share: b4a.toString(D, 'hex'),
      witness: {
        Y: b4a.toString(Y, 'hex'),
        proof: {
          A: b4a.toString(proof.A, 'hex'),
          B: b4a.toString(proof.B, 'hex'),
          z: b4a.toString(flipBit(proof.z, 5), 'hex')
        }
      }
    },
    penalty: 0
  }, { caller: 'local' })

  const e2 = await svc.evidence({ id: d2.id })
  assert(e2.appEvidence && e2.appEvidence.verdict === 'claim-supported',
    'end-to-end: tampered proof → claim-supported (' + (e2.appEvidence ? e2.appEvidence.reason : 'null') + ')')
}

// ── baseG sanity ─────────────────────────────────────────────────────────────
function testBaseG () {
  console.log('\n── 0. baseG returns the standard generator ──')
  const G = baseG()
  assert(G.byteLength === POINT_BYTES, 'baseG is 32 bytes')
  // Idempotent + defensive copy
  const G2 = baseG()
  assert(b4a.equals(G, G2), 'baseG stable across calls')
  G[0] ^= 1
  const G3 = baseG()
  assert(!b4a.equals(G, G3), 'baseG returns a defensive copy (caller mutation does not affect cache)')
}

// ── 12. FLAG 1 mitigation: subgroup-validity check rejects torsion points ──
async function testMixedOrderRejection () {
  console.log('\n── 12. Mixed-order / torsion points rejected (FLAG 1) ──')
  // Pull noble's published torsion-subgroup table — these are the 8
  // ed25519 points whose order divides 8 (i.e., live OUTSIDE the
  // prime-order subgroup). A pre-FLAG-1 verifier would accept these as
  // ordinary points; the new is_valid_point check rejects them.
  const { ED25519_TORSION_SUBGROUP } = await import('@noble/curves/ed25519.js')
  // ED25519_TORSION_SUBGROUP is noble's published table of the 8
  // ed25519 points whose order divides 8 — they live OUTSIDE the
  // prime-order subgroup. Format: hex strings of compressed encodings.
  const torsionBytes = ED25519_TORSION_SUBGROUP.map((hex) => b4a.from(hex, 'hex'))
  // Skip the identity (low-order-1 point) — sodium's is_valid_point
  // rejects it for being low-order anyway; we want non-identity torsion
  // points that a pre-FLAG-1 verifier might have accepted.
  const IDENTITY = (() => { const i = b4a.alloc(POINT_BYTES); i[0] = 1; return i })()
  const nonIdentityTorsion = torsionBytes.filter((p) => !b4a.equals(p, IDENTITY))
  assert(nonIdentityTorsion.length > 0, 'have at least one non-identity torsion point to test with')

  // Build a valid prove input, then swap each external point in turn for
  // a torsion point and check the verifier rejects with invalid-point.
  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })

  for (const fieldName of ['Y', 'C1', 'D', 'A', 'B']) {
    const tainted = { Y, C1, D, ...proof }
    tainted[fieldName] = nonIdentityTorsion[0]
    const r = verifyShareEquality(tainted)
    assert(!r.valid, fieldName + ' replaced with torsion point → invalid')
    assert(r.reason === 'invalid-point:' + fieldName,
      'reason names the offending field (' + r.reason + ')')
  }

  // Also confirm proveShareEquality refuses torsion inputs at prove time.
  let threw = false
  let msg = null
  try {
    proveShareEquality({ x, Y: nonIdentityTorsion[0], C1, D })
  } catch (err) { threw = true; msg = err.message }
  assert(threw && /invalid point Y/.test(msg),
    'proveShareEquality throws on torsion Y (' + (msg || 'no throw') + ')')
}

// ── 13. FLAG 2 mitigation: noble scalar mul stress test ─────────────────────
async function testNobleScalarMulStress () {
  console.log('\n── 13. Noble Fn.mul stress (FLAG 2) ──')
  // Run 50 fresh prove+verify cycles with distinct secrets. Catches any
  // off-by-one / byte-ordering / reduction bug in the noble integration
  // that random-test-1 (the round-trip in test 1) wouldn't reliably
  // catch on a single sample.
  let allOk = true
  for (let i = 0; i < 50; i++) {
    const x = randomScalar()
    const Y = publicFromSecret(x)
    const C1 = randomPoint()
    const D = shareFor(x, C1)
    const proof = proveShareEquality({ x, Y, C1, D })
    const r = verifyShareEquality({ Y, C1, D, ...proof })
    if (!r.valid) {
      allOk = false
      console.log('    failed at iteration ' + i + ': ' + r.reason)
      break
    }
  }
  assert(allOk, '50 random prove+verify cycles all succeed (noble Fn.mul self-consistent)')

  // Sanity: a proof produced by the new (noble-backed) prover MUST
  // verify under the same verifier — proves the noble↔sodium scalar
  // encoding round-trips through the addition z = k + e*x.
  const x = randomScalar()
  const Y = publicFromSecret(x)
  const C1 = randomPoint()
  const D = shareFor(x, C1)
  const proof = proveShareEquality({ x, Y, C1, D })
  // z must be in [0, ℓ) — sodium's scalar_add would have failed loudly
  // if it weren't. Smoke-test by attempting reduction round-trip.
  const reduced = b4a.alloc(SCALAR_BYTES)
  // crypto_core_ed25519_scalar_reduce expects 64 bytes; we just sanity-
  // check that z's first byte isn't trivially malformed.
  assert(proof.z.byteLength === SCALAR_BYTES, 'proof.z is 32 bytes')
  assert(proof.A.byteLength === POINT_BYTES, 'proof.A is 32 bytes')
  assert(proof.B.byteLength === POINT_BYTES, 'proof.B is 32 bytes')
}

async function main () {
  testBaseG()
  testRoundTrip()
  testTamperMatrix()
  testArbitrationWrapper()
  await testEndToEnd()
  await testMixedOrderRejection()
  await testNobleScalarMulStress()
  console.log(`\n── done: ${passed} passed, ${failed} failed ──`)
  process.exit(failed === 0 ? 0 : 1)
}

main().catch((err) => {
  console.error('fatal:', err)
  process.exit(2)
})