Files
zeekay 07ffec05a7 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.
2026-05-11 22:54:56 -07:00

296 lines
11 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/consensus/config"
"golang.org/x/crypto/sha3"
)
// ----------------------------------------------------------------------------
// HIP-0077 F34 — Quasar round digest
// ----------------------------------------------------------------------------
//
// ComputeRoundDigest binds EVERY consensus envelope axis into the 32-byte
// subject the threshold signature signs over for a Quasar consensus round.
//
// A different DKG transcript, different committee, different group key,
// different proof backend, different proof format, different verifier,
// different identity scheme, or different data-availability root MUST
// produce a different digest, even when (network, chain, epoch, height,
// round, parent_state) are byte-equal.
//
// Concretely, the digest binds:
//
// profile_id security-profile envelope (ChainSecurityProfile.ProfileID)
// hash_suite_id hash family bound as DATA (kernel is cSHAKE256)
// identity_scheme_id validator identity-signature scheme (FIPS 204 ML-DSA)
// finality_scheme_id threshold finality kernel (Pulsar-M-44/65/87)
// proof_policy_id policy class (STARK_FRI_SHA3_PQ / STARK_FRI_Keccak)
// proof_backend_id implementation (SP1 / RISC0 / P3Q / Stone / Stwo)
// proof_format_id wire byte layout of the proof bytes
// verifier_id concrete pinned verifier identity
// network_id, chain_id envelope replay axes
// epoch, height, round_or_view consensus position
// parent_qblock_hash chain tip extension
// payload_root block-specific payload anchor
// da_root data-availability root
// lux_state_root Lux state commitment for the round
// zchain_state_root latest accepted Z-Chain root
// validator_set_root validator registry root
// committee_root committee Merkle root
// dkg_transcript_root Pedersen-DKG transcript root
// group_public_key_hash threshold group public-key hash
// signer_set_or_bitmap_commit who attested (bitmap or weight-Merkle root)
//
// Hash family: cSHAKE256 / TupleHash256 (FIPS 202 + SP 800-185), independent
// of HashSuiteID — HashSuiteID is bound as DATA, not as the kernel that
// produces the digest. Customization tag pins the layout to "QUASAR-ROUND-
// DIGEST"; the tag string is the schema identity and is itself bound by
// the cSHAKE256 customization stream.
//
// Layout (TupleHash256, customization "QUASAR-ROUND-DIGEST"):
//
// parts[0] = "Quasar/RoundDigest" (in-band protocol tag)
// parts[1] = profile_id (4 BE)
// parts[2] = hash_suite_id (1)
// parts[3] = identity_scheme_id (1)
// parts[4] = finality_scheme_id (1)
// parts[5] = proof_policy_id (1)
// parts[6] = proof_backend_id (1)
// parts[7] = proof_format_id (1)
// parts[8] = verifier_id (2 BE)
// parts[9] = network_id (4 BE)
// parts[10] = chain_id (4 BE)
// parts[11] = epoch (8 BE)
// parts[12] = height (8 BE)
// parts[13] = round_or_view (4 BE)
// parts[14] = parent_qblock_hash ([32]byte)
// parts[15] = payload_root ([48]byte)
// parts[16] = da_root ([48]byte)
// parts[17] = lux_state_root ([48]byte)
// parts[18] = zchain_state_root ([48]byte)
// parts[19] = validator_set_root ([48]byte)
// parts[20] = committee_root ([48]byte)
// parts[21] = dkg_transcript_root ([48]byte)
// parts[22] = group_public_key_hash ([48]byte)
// parts[23] = signer_set_or_bitmap_commitment ([48]byte)
//
// Every part is bound via SP 800-185 encode_string framing, so flipping
// any byte of any field yields a different digest. TupleHash injectivity
// is the formal guarantee.
//
// ComputeRoundDigest is the single production entry point. It refuses any
// zero-value security-relevant argument up-front; the error names which
// field was zero so the operator can fix the misconfiguration precisely.
func ComputeRoundDigest(
profileID uint32,
hashSuite config.HashSuiteID,
identityScheme config.IdentitySchemeID,
finalityScheme config.SigSchemeID,
proofPolicy config.ProofPolicyID,
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,
parentQBlockHash [32]byte,
payloadRoot [48]byte,
daRoot [48]byte,
sourceStateRoot [48]byte,
zchainStateRoot [48]byte,
validatorSetRoot [48]byte,
committeeRoot [48]byte,
dkgTranscriptRoot [48]byte,
groupPublicKeyHash [48]byte,
signerSetOrBitmapCommitment [48]byte,
) (RoundDigest, error) {
switch {
case profileID == 0:
return RoundDigest{}, fmt.Errorf("%w: profileID", ErrRoundDigestZeroField)
case hashSuite == config.HashSuiteNone:
return RoundDigest{}, fmt.Errorf("%w: hashSuite", ErrRoundDigestZeroField)
case identityScheme == config.IdentitySchemeNone:
return RoundDigest{}, fmt.Errorf("%w: identityScheme", ErrRoundDigestZeroField)
case finalityScheme == config.SigSchemeNone:
return RoundDigest{}, fmt.Errorf("%w: finalityScheme", ErrRoundDigestZeroField)
case proofPolicy == config.ProofPolicyNone:
return RoundDigest{}, fmt.Errorf("%w: proofPolicy", ErrRoundDigestZeroField)
case proofBackend == config.ProofBackendNone:
return RoundDigest{}, fmt.Errorf("%w: proofBackend", ErrRoundDigestZeroField)
case proofFormat == config.ProofFormatNone:
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:
return RoundDigest{}, fmt.Errorf("%w: chainID", ErrRoundDigestZeroField)
}
var u16 [2]byte
var u32 [4]byte
var u64 [8]byte
binary.BigEndian.PutUint32(u32[:], profileID)
profileBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint16(u16[:], uint16(verifierID))
verifierBytes := append([]byte(nil), u16[:]...)
binary.BigEndian.PutUint32(u32[:], networkID)
netBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint32(u32[:], chainID)
chainBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint64(u64[:], epoch)
epochBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint64(u64[:], height)
heightBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], roundOrView)
roundBytes := append([]byte(nil), u32[:]...)
parts := [][]byte{
[]byte(roundDigestProtocolTag),
profileBytes,
{byte(hashSuite)},
{byte(identityScheme)},
{byte(finalityScheme)},
{byte(proofPolicy)},
{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,
heightBytes,
roundBytes,
parentQBlockHash[:],
payloadRoot[:],
daRoot[:],
sourceStateRoot[:],
zchainStateRoot[:],
validatorSetRoot[:],
committeeRoot[:],
dkgTranscriptRoot[:],
groupPublicKeyHash[:],
signerSetOrBitmapCommitment[:],
}
out := tupleHash256RoundDigest(parts, 32, roundDigestCustomization)
var digest RoundDigest
copy(digest[:], out)
return digest, nil
}
// roundDigestCustomization is the SP 800-185 cSHAKE256 customization tag
// for the canonical round-digest layout. The tag is the schema identity;
// changing it produces a digest stream that does not collide with any
// prior digest under the previous tag.
const roundDigestCustomization = "QUASAR-ROUND-DIGEST"
// roundDigestProtocolTag is the in-band redundant protocol tag bound as
// the first TupleHash part. Defence-in-depth so a cross-customization-
// collision attacker also has to forge the leading TupleHash part.
const roundDigestProtocolTag = "Quasar/RoundDigest"
// ErrRoundDigestZeroField is returned by ComputeRoundDigest when any
// security-relevant input is the zero value. The error names which field
// was zero so the operator can fix the misconfiguration precisely.
//
// Closes HIP-0077 red-review F77.
var ErrRoundDigestZeroField = errors.New(
"quasar: ComputeRoundDigest refuses zero-value security-relevant input")
// tupleHash256RoundDigest computes TupleHash256(parts, outLen, customization)
// per NIST SP 800-185 §5. The implementation mirrors the FIPS-aligned
// primitive in github.com/luxfi/corona/hash/sp800_185.go; vendored here
// to keep consensus below pulsar in the module dependency graph.
//
// TupleHash256 differs from naïve cSHAKE256(concat(parts)) in that each
// part is length-prefixed via encodeString — so (parts ["ab", "cd"]) and
// (parts ["a", "bcd"]) yield distinct digests. This is the property that
// makes flipping any single field's bytes change the digest, even if a
// neighbouring field could absorb the flipped bytes.
func tupleHash256RoundDigest(parts [][]byte, outLen int, customization string) []byte {
var x []byte
for _, p := range parts {
x = append(x, encodeStringSP800185(p)...)
}
x = append(x, rightEncodeSP800185(uint64(outLen)*8)...)
h := sha3.NewCShake256([]byte("TupleHash"), []byte(customization))
_, _ = h.Write(x)
out := make([]byte, outLen)
_, _ = h.Read(out)
return out
}
// leftEncodeSP800185 returns the SP 800-185 §2.3 left_encode(x) byte
// string. Operates on the BIT length, not the byte length — every
// caller multiplies by 8 before passing in.
func leftEncodeSP800185(x uint64) []byte {
if x == 0 {
return []byte{0x01, 0x00}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, byte(8-i))
out = append(out, buf[i:]...)
return out
}
// rightEncodeSP800185 returns the SP 800-185 §2.3 right_encode(x) byte
// string. Used at the tail of TupleHash to bind the requested output
// length into the absorbed stream.
func rightEncodeSP800185(x uint64) []byte {
if x == 0 {
return []byte{0x00, 0x01}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, buf[i:]...)
out = append(out, byte(8-i))
return out
}
// encodeStringSP800185 returns left_encode(bit_len(s)) || s per
// SP 800-185 §2.3. This is the unambiguous length-prefix framing that
// makes TupleHash injective over its tuple of parts.
func encodeStringSP800185(s []byte) []byte {
out := leftEncodeSP800185(uint64(len(s)) * 8)
out = append(out, s...)
return out
}