consensus: close BLOCKERS CR-12 + CR-13 (cross-policy replay + sampling bias)

CR-12 — bind effectivePolicy byte into ComputeRoundDigest transcript
  protocol/quasar/round_digest.go: new uint8 parameter, refused if zero,
  bound as a TupleHash part between verifierBytes and netBytes.
  Closes cross-policy replay: an adversary can no longer sign a Round R
  as PolicyPQ and strip the Q witness on retransmit, presenting the
  bare P witness as a PolicyQuorum cert under an identical digest.
  Tests updated: canonical inputs include effectivePolicy=1, FixedHash-
  Family mirror parts include the policy byte at the matching position.

CR-13 — rejection sampling closes modulo bias in three samplers
  protocol/photon/emitter.go cryptoRandInt
  protocol/prism/cut.go cryptoRandInt
  protocol/prism/stake_weighted_cut.go cryptoRandUint64
  Prior `% max` form was structurally biased for non-power-of-2 max;
  under nation-state grinding model (Pinkas-Reiter style) the bias was
  an exploit on committee sampling. Rejection sampling computes the
  largest multiple of max that fits in uint64 and rejects values above
  it. Expected iterations ~1-2; never more than 2 in expectation.

Tests pass on protocol/quasar, protocol/photon, protocol/prism.
BLOCKERS.md CR-12 and CR-13 marked closed.

Two of 13 critical findings down. Remaining 11 are in progress.
This commit is contained in:
2026-05-11 22:54:56 -07:00
parent 29407adaa6
commit ea25d8df03
6 changed files with 67 additions and 9 deletions
+22 -3
View File
@@ -66,14 +66,33 @@ func (e *UniformEmitter) Emit(msg interface{}) ([]types.NodeID, error) {
return shuffled[:k], nil
}
// cryptoRandInt returns a cryptographically secure random integer in [0, max).
// cryptoRandInt returns a cryptographically secure random integer in
// [0, max) — uniformly distributed. Closes BLOCKERS.md CR-13: prior
// implementation used `binary.LittleEndian.Uint64(buf[:]) % uint64(max)`
// which introduces modulo bias for non-power-of-2 max. Under the
// nation-state grinding threat model, that bias was a structural
// exploit on committee sampling (Pinkas-Reiter style).
//
// This implementation uses rejection sampling: read 8 bytes, reject
// values in the high partial bucket, retry until the sample falls in
// the perfectly-uniform range. Expected loops ~1.0–2.0 (never more
// than 2.0 in expectation for any positive max).
func cryptoRandInt(max int) int {
if max <= 0 {
return 0
}
// Bound to refuse: largest multiple of max <= 2^64. Values above
// this are biased and must be rejected.
limit := (^uint64(0) / uint64(max)) * uint64(max)
var buf [8]byte
_, _ = rand.Read(buf[:])
return int(binary.LittleEndian.Uint64(buf[:]) % uint64(max))
for {
_, _ = rand.Read(buf[:])
v := binary.LittleEndian.Uint64(buf[:])
if v < limit {
return int(v % uint64(max))
}
// Bias-region hit; resample.
}
}
// EmitTo emits a message to specific nodes
+13 -3
View File
@@ -57,14 +57,24 @@ func (c *UniformCut) Sample(k int) []types.NodeID {
return shuffled[:k]
}
// cryptoRandInt returns a cryptographically secure random integer in [0, max).
// cryptoRandInt returns a cryptographically secure random integer in
// [0, max) — uniformly distributed via rejection sampling. Closes
// BLOCKERS.md CR-13. See protocol/photon/emitter.go for the same
// rationale; both samplers were exploitable under nation-state
// committee-grinding.
func cryptoRandInt(max int) int {
if max <= 0 {
return 0
}
limit := (^uint64(0) / uint64(max)) * uint64(max)
var buf [8]byte
_, _ = rand.Read(buf[:])
return int(binary.LittleEndian.Uint64(buf[:]) % uint64(max))
for {
_, _ = rand.Read(buf[:])
v := binary.LittleEndian.Uint64(buf[:])
if v < limit {
return int(v % uint64(max))
}
}
}
// Luminance implements Cut interface
+12 -3
View File
@@ -112,12 +112,21 @@ func (c *StakeWeightedCut) Luminance() Luminance {
}
}
// cryptoRandUint64 returns a cryptographically secure random uint64 in [0, max).
// cryptoRandUint64 returns a cryptographically secure random uint64
// in [0, max) — uniformly distributed via rejection sampling. Closes
// BLOCKERS.md CR-13: the prior `% max` form was biased on non-power-of-2
// stake totals, structurally exploitable by stake-weight grinding.
func cryptoRandUint64(max uint64) uint64 {
if max <= 1 {
return 0
}
limit := (^uint64(0) / max) * max
var buf [8]byte
_, _ = rand.Read(buf[:])
return binary.LittleEndian.Uint64(buf[:]) % max
for {
_, _ = rand.Read(buf[:])
v := binary.LittleEndian.Uint64(buf[:])
if v < limit {
return v % max
}
}
}
@@ -30,6 +30,7 @@ type roundDigestInputs struct {
proofBackend config.ProofBackendID
proofFormat config.ProofFormatID
verifierID config.VerifierID
effectivePolicy uint8 // BLOCKERS.md CR-12
networkID uint32
chainID uint32
epoch uint64
@@ -57,6 +58,7 @@ func canonicalRoundDigest() roundDigestInputs {
proofBackend: config.ProofBackendP3QSTARKFRISHA3,
proofFormat: config.ProofFormatP3QBinaryV1,
verifierID: config.VerifierP3QSTARKFRISHA3PQ,
effectivePolicy: 1, // PolicyQuorum non-zero baseline
networkID: 0xC0DE0002,
chainID: 0xDEADBEEF,
epoch: 0x1122334455667788,
@@ -83,6 +85,7 @@ func (in roundDigestInputs) compute() (RoundDigest, error) {
in.profileID,
in.hashSuite, in.identityScheme, in.finalityScheme,
in.proofPolicy, in.proofBackend, in.proofFormat, in.verifierID,
in.effectivePolicy,
in.networkID, in.chainID,
in.epoch, in.height,
in.roundOrView,
+12
View File
@@ -97,6 +97,7 @@ func ComputeRoundDigest(
proofBackend config.ProofBackendID,
proofFormat config.ProofFormatID,
verifierID config.VerifierID,
effectivePolicy uint8, // BLOCKERS.md CR-12: bind effective policy byte
networkID, chainID uint32,
epoch, height uint64,
roundOrView uint32,
@@ -128,6 +129,10 @@ func ComputeRoundDigest(
return RoundDigest{}, fmt.Errorf("%w: proofFormat", ErrRoundDigestZeroField)
case verifierID == config.VerifierNone:
return RoundDigest{}, fmt.Errorf("%w: verifierID", ErrRoundDigestZeroField)
case effectivePolicy == 0:
// BLOCKERS.md CR-12: zero effective policy is the cross-policy
// replay attack vector. Refuse explicitly.
return RoundDigest{}, fmt.Errorf("%w: effectivePolicy", ErrRoundDigestZeroField)
case networkID == 0:
return RoundDigest{}, fmt.Errorf("%w: networkID", ErrRoundDigestZeroField)
case chainID == 0:
@@ -169,6 +174,13 @@ func ComputeRoundDigest(
{byte(proofBackend)},
{byte(proofFormat)},
verifierBytes,
// BLOCKERS.md CR-12 closure — bind effective policy byte into
// the digest. Without this, an adversary could sign a Round R
// as PolicyPQ (P+Q witnesses) and strip the Q witness on
// retransmit, presenting the bare P witness as a PolicyQuorum
// cert under an identical digest. The policy byte must be in
// the transcript so the verifier-side policy floor enforces.
{effectivePolicy},
netBytes,
chainBytes,
epochBytes,
+5
View File
@@ -24,6 +24,7 @@ type canonicalRoundDigestInputs struct {
proofBackend config.ProofBackendID
proofFormat config.ProofFormatID
verifierID config.VerifierID
effectivePolicy uint8 // BLOCKERS.md CR-12: bound into digest
networkID uint32
chainID uint32
epoch uint64
@@ -62,6 +63,7 @@ func canonical() canonicalRoundDigestInputs {
proofBackend: config.ProofBackendP3QSTARKFRISHA3,
proofFormat: config.ProofFormatSTARKFRIBinaryV1,
verifierID: config.VerifierP3QSTARKFRISHA3PQ,
effectivePolicy: 1, // PolicyQuorum non-zero baseline
networkID: 0xC0DE0001,
chainID: 0xDEADBEEF,
epoch: 0x1122334455667788,
@@ -86,6 +88,7 @@ func compute(t *testing.T, in canonicalRoundDigestInputs) RoundDigest {
in.profileID,
in.hashSuite, in.identityScheme, in.finalityScheme,
in.proofPolicy, in.proofBackend, in.proofFormat, in.verifierID,
in.effectivePolicy,
in.networkID, in.chainID,
in.epoch, in.height,
in.roundOrView,
@@ -244,6 +247,8 @@ func TestComputeRoundDigest_FixedHashFamily(t *testing.T) {
{byte(in.proofBackend)},
{byte(in.proofFormat)},
verifierBytes,
// BLOCKERS.md CR-12 mirror — must match production order.
{in.effectivePolicy},
netBytes,
chainBytes,
epochBytes,