#!/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)
})