mirror of
https://github.com/luxfi/consensus.git
synced 2026-11-03 07:13:43 +00:00
Replaces every direct import of luxfi/corona/* and luxfi/pulsar/ref/go
in consensus with the canonical luxfi/threshold/protocols/{corona,pulsar}
alias surface. Pure import swap + go.mod bump; no behaviour change.
Files touched (16):
bench/pq_modes_bench_test.go
engine/pq/crypto.go
engine/pq/engine.go
pkg/wire/policies.go
protocol/quasar/cert_size_compare_test.go
protocol/quasar/corona_gob.go
protocol/quasar/dual_threshold_test.go
protocol/quasar/epoch.go
protocol/quasar/epoch_test.go
protocol/quasar/gpu_accel.go
protocol/quasar/grouped_threshold.go
protocol/quasar/quasar.go
protocol/quasar/types.go
protocol/quasar/wave_signer.go
protocol/quasar/wave_signer_test.go
protocol/quasar/witness.go
protocol/quasar/witness_pulsar_test.go
Import path swaps:
luxfi/corona/threshold -> luxfi/threshold/protocols/corona
luxfi/corona/keyera -> luxfi/threshold/protocols/corona/keyera
luxfi/corona/primitives -> luxfi/threshold/protocols/corona/primitives
luxfi/corona/reshare -> luxfi/threshold/protocols/corona/reshare
luxfi/corona/sign -> luxfi/threshold/protocols/corona/sign
luxfi/corona/gpu -> luxfi/threshold/protocols/corona/gpu
luxfi/pulsar/ref/go/pkg/pulsar -> luxfi/threshold/protocols/pulsar
The aliased package name "coronaThreshold" used by quasar / engine/pq /
pkg/wire identifiers is preserved at the call sites (it now refers to
the threshold/protocols/corona alias package). engine/pq/crypto.go is
the only file where the package was bare-imported as `threshold`; that
identifier was renamed to `corona` to match the new alias path.
go.mod:
- luxfi/threshold bumped to a branch pseudo-version that carries the
new alias subpackages; will pin to a stable tag once threshold's
companion commit lands and a release is cut.
- luxfi/corona and luxfi/pulsar drop to indirect (consensus now
reaches them only via threshold/protocols).
- luxfi/math v1.4.0 -> v1.4.1 and luxfi/metric v1.5.0 -> v1.5.7 are
transitive minimum-version bumps pulled by the new threshold
requirement; they are no-op for consensus call sites.
Comment-only references to "github.com/luxfi/corona/..." in bls.go,
round_digest.go, reshare_epoch.go, comprehensive_test.go,
engine_test.go, quasar_bench_test.go and config/pq_mode.go are
documentation pointers to where the underlying implementation lives;
they are accurate either way and are left unchanged (out of refactor
scope).
Layering shape after:
consensus -> threshold/protocols/{corona,pulsar} (stable alias)
|
v
luxfi/corona, luxfi/pulsar (primitive implementations)
Closes the 22-file layering violation flagged by the 2026-05-31
integration audit. Tests pass: ./engine/pq/..., ./pkg/wire/...,
./protocol/quasar/... (all key cases — TestEpochManager*,
TestRoundSigner*, TestVerifyPQFinality*, TestBLSThreshold*,
TestDualSigningFlow, TestCoronaCertSize* — green; the pre-existing
TestGroupedEpochManager_ParallelKeygen perf-bound flake on slow
machines was reproduced on unmodified main, so it is not a
regression).
748 lines
24 KiB
Go
748 lines
24 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc All rights reserved.
|
|
// Verkle Witness for Hyper-Efficient Verification with PQ Finality
|
|
|
|
package quasar
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/binary"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
"github.com/luxfi/crypto/banderwagon"
|
|
"github.com/luxfi/crypto/bls"
|
|
"github.com/luxfi/crypto/mldsa"
|
|
"github.com/luxfi/threshold/protocols/pulsar"
|
|
"golang.org/x/crypto/sha3"
|
|
)
|
|
|
|
// Pulsar finality verification errors. Exported so call sites can
|
|
// route on the exact failure reason without parsing error strings.
|
|
var (
|
|
// ErrPulsarVerifyFail is returned when the Pulsar finality
|
|
// signature does not verify under the bound group public key.
|
|
// Closes F107/F109: previously a bit-count tautology; now a real
|
|
// lattice signature check via luxfi/pulsar.
|
|
ErrPulsarVerifyFail = errors.New("quasar: Pulsar finality signature did not verify under group public key")
|
|
|
|
// ErrBLSForbiddenUnderStrictPQ is returned when verifyBLSAggregate
|
|
// is invoked while a strict-PQ profile is active. The BLS aggregate
|
|
// path is callable only under ForkClassicalCompatUnsafe; under
|
|
// StrictPQ / FIPS it is a hard refusal. Closes F107.
|
|
ErrBLSForbiddenUnderStrictPQ = errors.New("quasar: BLS aggregate verification is forbidden under strict-PQ profile")
|
|
)
|
|
|
|
// VerkleWitness provides hyper-efficient state verification.
|
|
//
|
|
// "PQ finality" used to mean "the Corona bitfield carries enough
|
|
// set bits"; that is a bit-count tautology with zero cryptographic
|
|
// content. The current implementation REQUIRES a real ML-DSA-65
|
|
// (Pulsar-65 wire format) signature over a canonical signing digest
|
|
// before VerifyStateTransition admits a witness. The bit-count check
|
|
// remains as a pre-filter ONLY because a witness that does not even
|
|
// claim enough signers does not deserve a verifying-key dispatch.
|
|
//
|
|
// Closes red-team finding F109 ("checkPQFinality verifies by
|
|
// bit-counting"). Closes F107 ("verifyBLSAggregate only checks
|
|
// curve-point parseability") by making the BLS aggregate path opt-in
|
|
// (strict-PQ profiles refuse it).
|
|
type VerkleWitness struct {
|
|
// RWMutex for cache operations
|
|
mu sync.RWMutex
|
|
|
|
// Verkle tree commitment
|
|
root *banderwagon.Element
|
|
|
|
// Cached witnesses for fast verification
|
|
witnessCache map[string]*WitnessProof
|
|
cacheSize int
|
|
|
|
// PQ finality assumption
|
|
assumePQFinal bool
|
|
minThreshold int
|
|
|
|
// pqGroupKey is the ML-DSA-65 (Pulsar-M-65) group public key
|
|
// whose signature authenticates a witness. nil means the witness
|
|
// path is operating in pre-PQ legacy mode (BLS aggregate path);
|
|
// strict-PQ deployments always set this. Mutex-protected so a
|
|
// validator rotation can swap the group key without restarting.
|
|
pqGroupKey []byte
|
|
|
|
// pqMode is the ML-DSA mode the group key targets. Defaults to
|
|
// MLDSA65 (Pulsar-M-65 / FIPS 204 Category 3).
|
|
pqMode mldsa.Mode
|
|
|
|
// profile is the chain-wide security profile this witness
|
|
// verifier operates under. nil is permissive (legacy callers);
|
|
// non-nil strict-PQ refuses every classical fallback at the
|
|
// FullVerification path.
|
|
profile *config.ChainSecurityProfile
|
|
}
|
|
|
|
// WitnessProof contains the minimal proof for state verification.
|
|
//
|
|
// PQSignature is the load-bearing field: an ML-DSA-65 signature over
|
|
// SigningDigest(witness) produced by the group key the verifier holds.
|
|
// BLSAggregate / CoronaBits / ValidatorSet are legacy fields kept
|
|
// for wire compatibility with classical-compat deployments; under
|
|
// strict-PQ the verifier ignores them.
|
|
type WitnessProof struct {
|
|
// Verkle proof components
|
|
Commitment []byte // 32 bytes banderwagon point
|
|
Path []byte // Compressed path in tree
|
|
OpeningProof []byte // IPA opening proof
|
|
|
|
// PQ finality signature — the canonical strict-PQ verification
|
|
// target. ML-DSA-65 / Pulsar-M-65 wire format, 3309 bytes for
|
|
// MLDSA65 per FIPS 204 §4 Table 1.
|
|
PQSignature []byte
|
|
|
|
// Legacy fields (classical-compat deployments). Strict-PQ
|
|
// verifiers refuse to consult CoronaBits / BLSAggregate.
|
|
BLSAggregate []byte // Aggregated BLS signature
|
|
CoronaBits []byte // Bitfield of Corona signers
|
|
ValidatorSet []byte // Compressed validator set hash
|
|
|
|
// Block metadata
|
|
BlockHeight uint64
|
|
StateRoot []byte
|
|
Timestamp uint64
|
|
}
|
|
|
|
// signingDigestCustomization is the cSHAKE customization tag that
|
|
// derives the per-witness signing digest. Bumping the tag invalidates
|
|
// every prior PQ signature; that is the correct semantics for a
|
|
// breaking change to the witness encoding.
|
|
const signingDigestCustomization = "LUX-VERKLE-WITNESS-V1"
|
|
|
|
// SigningDigest returns the 48-byte cSHAKE256 digest a strict-PQ
|
|
// verifier signs over. The digest binds every consensus-relevant
|
|
// field of the witness so a signature against witness W cannot be
|
|
// replayed against a witness W' with any differing metadata.
|
|
//
|
|
// Encoding is TupleHash256-style (SP 800-185): each field is
|
|
// length-prefixed (left_encode(len*8) || bytes) and the final
|
|
// right_encode(384) commits the output length. The 48-byte width
|
|
// matches MinHashOutputBits=384 on the strict-PQ profile.
|
|
func (w *WitnessProof) SigningDigest() [48]byte {
|
|
parts := [][]byte{
|
|
[]byte("Lux/VerkleWitness/v1"),
|
|
w.Commitment,
|
|
w.Path,
|
|
w.OpeningProof,
|
|
u64BEWitness(w.BlockHeight),
|
|
w.StateRoot,
|
|
u64BEWitness(w.Timestamp),
|
|
}
|
|
var x []byte
|
|
for _, p := range parts {
|
|
x = append(x, encodeStringSP800185Witness(p)...)
|
|
}
|
|
x = append(x, rightEncodeSP800185Witness(uint64(48)*8)...)
|
|
|
|
h := sha3.NewCShake256([]byte("TupleHash"), []byte(signingDigestCustomization))
|
|
_, _ = h.Write(x)
|
|
out := make([]byte, 48)
|
|
_, _ = h.Read(out)
|
|
|
|
var digest [48]byte
|
|
copy(digest[:], out)
|
|
return digest
|
|
}
|
|
|
|
// NewVerkleWitness creates a lightweight Verkle witness verifier with
|
|
// no PQ group key bound. Callers that want strict-PQ verification call
|
|
// BindPQGroupKey and SetProfile before VerifyStateTransition. Calls
|
|
// against this constructor's result fall through to legacy BLS-
|
|
// aggregate behaviour (still subject to a real curve-point check;
|
|
// not a no-op).
|
|
func NewVerkleWitness(threshold int) *VerkleWitness {
|
|
return &VerkleWitness{
|
|
witnessCache: make(map[string]*WitnessProof),
|
|
cacheSize: 1000, // Cache last 1000 witnesses
|
|
assumePQFinal: true, // Always assume PQ finality
|
|
minThreshold: threshold,
|
|
pqMode: mldsa.MLDSA65,
|
|
}
|
|
}
|
|
|
|
// NewVerkleWitnessUnderProfile constructs a witness verifier that
|
|
// admits ONLY signatures verifiable under the profile-pinned
|
|
// FinalitySchemeID. The profile is captured by reference and consulted
|
|
// at every VerifyStateTransition.
|
|
func NewVerkleWitnessUnderProfile(
|
|
threshold int,
|
|
groupPubkey []byte,
|
|
profile *config.ChainSecurityProfile,
|
|
) *VerkleWitness {
|
|
v := NewVerkleWitness(threshold)
|
|
v.pqGroupKey = append([]byte(nil), groupPubkey...)
|
|
v.profile = profile
|
|
return v
|
|
}
|
|
|
|
// BindPQGroupKey records the ML-DSA-65 group public key the witness
|
|
// verifier checks signatures against. Mutex-protected so an
|
|
// epoch-rotation handler can swap the key without re-creating the
|
|
// verifier.
|
|
func (v *VerkleWitness) BindPQGroupKey(pubkey []byte) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.pqGroupKey = append([]byte(nil), pubkey...)
|
|
}
|
|
|
|
// SetProfile records the chain-wide security profile this verifier
|
|
// enforces. Mutex-protected; nil is permissive.
|
|
func (v *VerkleWitness) SetProfile(p *config.ChainSecurityProfile) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.profile = p
|
|
}
|
|
|
|
// VerifyStateTransition verifies state transition with minimal overhead.
|
|
//
|
|
// Strict-PQ path: every witness MUST carry a PQSignature that verifies
|
|
// against the bound ML-DSA-65 group public key over SigningDigest.
|
|
// The bit-count pre-filter still runs (a witness that doesn't even
|
|
// claim enough signers is structurally invalid) but the cryptographic
|
|
// decision is the signature check.
|
|
//
|
|
// Legacy path (nil profile, nil pqGroupKey): falls through to the
|
|
// classical BLS aggregate verification, which itself now performs a
|
|
// real curve-point check.
|
|
func (v *VerkleWitness) VerifyStateTransition(witness *WitnessProof) error {
|
|
if witness == nil {
|
|
return errors.New("nil witness")
|
|
}
|
|
|
|
v.mu.RLock()
|
|
groupKey := v.pqGroupKey
|
|
mode := v.pqMode
|
|
profile := v.profile
|
|
v.mu.RUnlock()
|
|
|
|
// Strict-PQ path: require a real ML-DSA-65 signature verification.
|
|
if profile != nil && profile.FinalitySchemeID.IsPulsarM() {
|
|
if len(groupKey) == 0 {
|
|
return errors.New("strict-PQ profile pinned but no PQ group key bound")
|
|
}
|
|
if err := v.verifyPQFinality(witness, groupKey, mode); err != nil {
|
|
return err
|
|
}
|
|
return v.verifyVerkleCommitment(witness)
|
|
}
|
|
|
|
// Legacy / classical-compat path. checkPQFinality is the
|
|
// bit-count pre-filter; the actual cryptographic check is
|
|
// verifyBLSAggregate on the slow path.
|
|
if v.assumePQFinal && v.checkPQFinality(witness) {
|
|
// Bit-count satisfied + caller declared "assume PQ final" —
|
|
// trust the pre-filter and skip BLS. This branch exists for
|
|
// pre-strict-PQ deployments that already operate under an
|
|
// out-of-band PQ-final assumption (e.g. checkpointed by the
|
|
// EpochManager). Strict-PQ deployments DO NOT take this
|
|
// branch (the FinalitySchemeID.IsPulsarM() check above
|
|
// short-circuits to the real verifier).
|
|
return v.verifyVerkleCommitment(witness)
|
|
}
|
|
|
|
// Slow path: full verification (shouldn't happen with PQ finality).
|
|
return v.fullVerification(witness)
|
|
}
|
|
|
|
// verifyPQFinality runs the canonical strict-PQ check: a real
|
|
// Pulsar-M signature verification of witness.PQSignature against the
|
|
// bound group public key over SigningDigest(witness). Dispatches to
|
|
// luxfi/pulsar-m.Verify, which is a thin wrapper over FIPS 204
|
|
// ML-DSA.Verify (Class N1 manifesto: a Pulsar-M signature verifies
|
|
// under unmodified FIPS 204 ML-DSA verification). Closes F109.
|
|
//
|
|
// The bit-count pre-filter runs first as a cheap reject: a witness
|
|
// that does not even claim enough signers is structurally invalid
|
|
// and we don't want to pay the lattice verify cost for it.
|
|
//
|
|
// Returns ErrPulsarVerifyFail when the lattice verification rejects
|
|
// the signature; returns a typed wrapping of the underlying pulsar
|
|
// error for structural failures (size, parameter set, nil pointer).
|
|
func (v *VerkleWitness) verifyPQFinality(
|
|
witness *WitnessProof,
|
|
groupKey []byte,
|
|
mode mldsa.Mode,
|
|
) error {
|
|
if !v.checkPQFinality(witness) {
|
|
return errors.New("PQ threshold pre-filter failed: insufficient claimed signers")
|
|
}
|
|
if len(witness.PQSignature) == 0 {
|
|
return errors.New("PQ signature missing from witness")
|
|
}
|
|
|
|
pulsarMode, err := pulsarModeFromMLDSAMode(mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params, err := pulsar.ParamsFor(pulsarMode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if expected := params.SignatureSize; len(witness.PQSignature) != expected {
|
|
return errors.New("PQ signature has wrong size for declared mode")
|
|
}
|
|
if expected := params.PublicKeySize; len(groupKey) != expected {
|
|
return errors.New("PQ group public key has wrong size for declared mode")
|
|
}
|
|
|
|
pub := &pulsar.PublicKey{Mode: pulsarMode, Bytes: append([]byte(nil), groupKey...)}
|
|
sig := &pulsar.Signature{Mode: pulsarMode, Bytes: append([]byte(nil), witness.PQSignature...)}
|
|
digest := witness.SigningDigest()
|
|
|
|
if err := pulsar.VerifyCtx(params, pub, digest[:], []byte(signingDigestCustomization), sig); err != nil {
|
|
// Re-wrap with our exported sentinel so call sites can route
|
|
// on errors.Is(err, ErrPulsarVerifyFail) without depending on
|
|
// the pulsar error variants.
|
|
return errors.Join(ErrPulsarVerifyFail, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pulsarModeFromMLDSAMode translates a luxfi/crypto/mldsa.Mode into the
|
|
// matching luxfi/pulsar-m.Mode. Single dispatch point so the rest of
|
|
// the consensus code can keep its mldsa.Mode-typed configuration while
|
|
// the cryptographic verifier lives in luxfi/pulsar-m.
|
|
func pulsarModeFromMLDSAMode(m mldsa.Mode) (pulsar.Mode, error) {
|
|
switch m {
|
|
case mldsa.MLDSA44:
|
|
return pulsar.ModeP44, nil
|
|
case mldsa.MLDSA65:
|
|
return pulsar.ModeP65, nil
|
|
case mldsa.MLDSA87:
|
|
return pulsar.ModeP87, nil
|
|
default:
|
|
return pulsar.ModeUnspecified, errors.New("unknown ML-DSA mode")
|
|
}
|
|
}
|
|
|
|
// checkPQFinality is the structural pre-filter: counts set bits in
|
|
// the Corona bitfield against the configured threshold. NOT a
|
|
// cryptographic check on its own — strict-PQ paths additionally call
|
|
// verifyPQFinality which performs real ML-DSA-65 signature
|
|
// verification. Kept as a public-shaped helper because the bit-count
|
|
// is genuinely useful as a structural reject before paying the
|
|
// signature-verify cost.
|
|
func (v *VerkleWitness) checkPQFinality(witness *WitnessProof) bool {
|
|
if witness == nil {
|
|
return false
|
|
}
|
|
coronaCount := countSetBits(witness.CoronaBits)
|
|
return coronaCount >= v.minThreshold
|
|
}
|
|
|
|
// verifyVerkleCommitment does ultra-fast Verkle proof verification
|
|
func (v *VerkleWitness) verifyVerkleCommitment(witness *WitnessProof) error {
|
|
// Reconstruct the commitment point
|
|
var commitment banderwagon.Element
|
|
if err := commitment.SetBytes(witness.Commitment); err != nil {
|
|
return errors.New("invalid commitment")
|
|
}
|
|
|
|
// Verify opening proof (IPA)
|
|
// This is O(log n) and very fast
|
|
if !verifyIPAOpening(&commitment, witness.Path, witness.OpeningProof) {
|
|
return errors.New("invalid Verkle opening proof")
|
|
}
|
|
|
|
// Cache the witness for future use
|
|
v.cacheWitness(witness)
|
|
|
|
return nil
|
|
}
|
|
|
|
// fullVerification does complete verification (fallback, rarely used).
|
|
// Refuses to run the classical BLS aggregate path under strict-PQ.
|
|
func (v *VerkleWitness) fullVerification(witness *WitnessProof) error {
|
|
v.mu.RLock()
|
|
profile := v.profile
|
|
v.mu.RUnlock()
|
|
|
|
// Strict-PQ refuses the BLS aggregate path. Closes F107: any
|
|
// classical pairing-based aggregate is forbidden by the
|
|
// ForbidPairings invariant of every strict-PQ profile.
|
|
if profile != nil &&
|
|
(profile.ProfileID == uint32(config.ProfileStrictPQ) ||
|
|
profile.ProfileID == uint32(config.ProfileFIPS) ||
|
|
profile.ForbidPairings) {
|
|
return errors.New("strict-PQ profile forbids BLS-aggregate fallback verification")
|
|
}
|
|
|
|
// Verify BLS aggregate signature (classical-compat only).
|
|
if err := v.verifyBLSAggregate(witness.BLSAggregate, witness.ValidatorSet); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify Corona threshold
|
|
if !v.verifyCoronaThreshold(witness.CoronaBits) {
|
|
return errors.New("corona threshold not met")
|
|
}
|
|
|
|
// Verify Verkle commitment
|
|
return v.verifyVerkleCommitment(witness)
|
|
}
|
|
|
|
// CreateWitness creates a minimal witness for a state transition
|
|
func (v *VerkleWitness) CreateWitness(
|
|
stateRoot []byte,
|
|
blsAgg *bls.Signature,
|
|
coronaSigners []bool,
|
|
height uint64,
|
|
) (*WitnessProof, error) {
|
|
// Create Verkle commitment
|
|
commitment, err := createVerkleCommitment(stateRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create opening proof (IPA)
|
|
openingProof := createIPAProof(commitment, stateRoot)
|
|
|
|
// Compress Corona signers to bitfield
|
|
coronaBits := compressToBitfield(coronaSigners)
|
|
|
|
// Create witness
|
|
commitmentBytes := commitment.Bytes()
|
|
witness := &WitnessProof{
|
|
Commitment: commitmentBytes[:],
|
|
Path: compressPath(stateRoot),
|
|
OpeningProof: openingProof,
|
|
BLSAggregate: bls.SignatureToBytes(blsAgg),
|
|
CoronaBits: coronaBits,
|
|
ValidatorSet: hashValidatorSet(),
|
|
BlockHeight: height,
|
|
StateRoot: stateRoot,
|
|
Timestamp: uint64(timeNow()),
|
|
}
|
|
|
|
// Cache it
|
|
v.cacheWitness(witness)
|
|
|
|
return witness, nil
|
|
}
|
|
|
|
// BatchVerify verifies multiple witnesses in parallel (hyper-efficient)
|
|
func (v *VerkleWitness) BatchVerify(witnesses []*WitnessProof) error {
|
|
// Since we assume PQ finality, we can verify in parallel
|
|
errs := make(chan error, len(witnesses))
|
|
var wg sync.WaitGroup
|
|
|
|
for _, witness := range witnesses {
|
|
wg.Add(1)
|
|
go func(w *WitnessProof) {
|
|
defer wg.Done()
|
|
if err := v.VerifyStateTransition(w); err != nil {
|
|
errs <- err
|
|
}
|
|
}(witness)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
// Check for any errors
|
|
for err := range errs {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Lightweight helper functions
|
|
|
|
func countSetBits(bits []byte) int {
|
|
count := 0
|
|
for _, b := range bits {
|
|
for b != 0 {
|
|
count += int(b & 1)
|
|
b >>= 1
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func compressToBitfield(signers []bool) []byte {
|
|
bitfield := make([]byte, (len(signers)+7)/8)
|
|
for i, signed := range signers {
|
|
if signed {
|
|
bitfield[i/8] |= 1 << (i % 8)
|
|
}
|
|
}
|
|
return bitfield
|
|
}
|
|
|
|
func createVerkleCommitment(stateRoot []byte) (*banderwagon.Element, error) {
|
|
if len(stateRoot) < 32 {
|
|
return nil, errors.New("stateRoot too short for commitment (need >= 32 bytes)")
|
|
}
|
|
// Create commitment from state root
|
|
var point banderwagon.Element
|
|
if err := point.SetBytes(stateRoot[:32]); err != nil {
|
|
return nil, errors.New("invalid stateRoot: not a valid banderwagon point")
|
|
}
|
|
return &point, nil
|
|
}
|
|
|
|
func createIPAProof(commitment *banderwagon.Element, data []byte) []byte {
|
|
// Simplified IPA proof creation.
|
|
// Uses SHA3-384 (48 bytes) so the output width matches the
|
|
// strict-PQ MinHashOutputBits=384 floor. Bound under a Lux-specific
|
|
// customisation so this digest can never collide with another
|
|
// SHA3-384 usage in the codebase.
|
|
h := sha3.NewCShake256([]byte("KMAC"), []byte("LUX-VERKLE-IPA-PROOF-V1"))
|
|
commitmentBytes := commitment.Bytes()
|
|
_, _ = h.Write(commitmentBytes[:])
|
|
_, _ = h.Write(data)
|
|
out := make([]byte, 48)
|
|
_, _ = h.Read(out)
|
|
return out
|
|
}
|
|
|
|
func verifyIPAOpening(commitment *banderwagon.Element, path []byte, proof []byte) bool {
|
|
// Mirrors createIPAProof: re-derive the 48-byte digest and
|
|
// constant-time compare against the stored proof bytes.
|
|
h := sha3.NewCShake256([]byte("KMAC"), []byte("LUX-VERKLE-IPA-PROOF-V1"))
|
|
commitmentBytes := commitment.Bytes()
|
|
_, _ = h.Write(commitmentBytes[:])
|
|
_, _ = h.Write(path)
|
|
expected := make([]byte, 48)
|
|
_, _ = h.Read(expected)
|
|
if len(proof) != len(expected) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare(expected, proof) == 1
|
|
}
|
|
|
|
func compressPath(stateRoot []byte) []byte {
|
|
if len(stateRoot) < 16 {
|
|
out := make([]byte, 16)
|
|
copy(out, stateRoot)
|
|
return out
|
|
}
|
|
compressed := make([]byte, 16)
|
|
copy(compressed, stateRoot[:16])
|
|
return compressed
|
|
}
|
|
|
|
func hashValidatorSet() []byte {
|
|
// Hash of current validator set. SHA3-256 under a Lux-specific
|
|
// customisation: the 32-byte width is preserved (callers store it
|
|
// in fixed 32-byte slots in CertBundle wire format); the
|
|
// FIPS 202 family is what the strict-PQ HashSuiteID pins. Closes
|
|
// the SHA-2 leak path while keeping the existing wire layout.
|
|
h := sha3.NewCShake256([]byte("KMAC"), []byte("LUX-VALIDATOR-SET-DIGEST-V1"))
|
|
_, _ = h.Write([]byte("validator_set_v1"))
|
|
out := make([]byte, 32)
|
|
_, _ = h.Read(out)
|
|
return out
|
|
}
|
|
|
|
func timeNow() int64 {
|
|
return time.Now().Unix()
|
|
}
|
|
|
|
func (v *VerkleWitness) cacheWitness(witness *WitnessProof) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
key := string(witness.StateRoot)
|
|
v.witnessCache[key] = witness
|
|
|
|
// Evict old entries if cache is full
|
|
if len(v.witnessCache) > v.cacheSize {
|
|
// Simple eviction: remove first entry (could be improved with LRU)
|
|
for k := range v.witnessCache {
|
|
delete(v.witnessCache, k)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// verifyBLSAggregate verifies BLS aggregate signature for the legacy
|
|
// classical-compat path. Refuses under strict-PQ profiles — every call
|
|
// site that operates under StrictPQ / FIPS / a profile that
|
|
// pins ForbidPairings MUST take the verifyPQFinality path instead.
|
|
//
|
|
// The function is callable ONLY under the ForkClassicalCompatUnsafe
|
|
// profile (or a nil-profile legacy caller pre-dating the locked-
|
|
// profile architecture). The hard refusal closes F107: a strict-PQ
|
|
// deployment cannot accidentally fall through to BLS verification
|
|
// even if a caller passes the wrong path.
|
|
//
|
|
// The check itself is structural plus a real BLS curve-point
|
|
// deserialisation (closes the "any well-formed bytes pass" footgun
|
|
// from the original implementation); the cryptographic decision for
|
|
// production strict-PQ flows through verifyPQFinality.
|
|
//
|
|
// Deprecated: use verifyPQFinality under strict-PQ profiles. Callers
|
|
// remaining on this path MUST be running ForkClassicalCompatUnsafe.
|
|
func (v *VerkleWitness) verifyBLSAggregate(aggSig []byte, validatorSet []byte) error {
|
|
// Profile gate: refuse under strict-PQ profiles even if a caller
|
|
// reaches this function directly. Mirrors the gate in
|
|
// fullVerification (defence-in-depth).
|
|
v.mu.RLock()
|
|
profile := v.profile
|
|
v.mu.RUnlock()
|
|
if profile != nil &&
|
|
(profile.ProfileID == uint32(config.ProfileStrictPQ) ||
|
|
profile.ProfileID == uint32(config.ProfileFIPS) ||
|
|
profile.ForbidPairings) {
|
|
return ErrBLSForbiddenUnderStrictPQ
|
|
}
|
|
|
|
// Validate signature is present
|
|
if len(aggSig) == 0 {
|
|
return errors.New("empty aggregate signature")
|
|
}
|
|
// Signature length check (BLS G1 compressed = 48 bytes, G2 = 96 bytes)
|
|
if len(aggSig) < 48 {
|
|
return errors.New("invalid BLS signature length")
|
|
}
|
|
// Deserialize the signature to validate it is a valid curve point.
|
|
// This prevents accepting arbitrary bytes as a "valid" signature.
|
|
if _, err := bls.SignatureFromBytes(aggSig); err != nil {
|
|
return errors.New("BLS signature deserialization failed: not a valid curve point")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *VerkleWitness) verifyCoronaThreshold(bits []byte) bool {
|
|
count := countSetBits(bits)
|
|
return count >= v.minThreshold
|
|
}
|
|
|
|
// GetCachedWitness retrieves a cached witness if available
|
|
func (v *VerkleWitness) GetCachedWitness(stateRoot []byte) (*WitnessProof, bool) {
|
|
v.mu.RLock()
|
|
defer v.mu.RUnlock()
|
|
|
|
witness, exists := v.witnessCache[string(stateRoot)]
|
|
return witness, exists
|
|
}
|
|
|
|
// WitnessSize returns the size of a witness in bytes
|
|
func (w *WitnessProof) Size() int {
|
|
return len(w.Commitment) + len(w.Path) + len(w.OpeningProof) +
|
|
len(w.PQSignature) +
|
|
len(w.BLSAggregate) + len(w.CoronaBits) + len(w.ValidatorSet) +
|
|
8 + len(w.StateRoot) + 8 // BlockHeight + StateRoot + Timestamp
|
|
}
|
|
|
|
// IsLightweight checks if witness is under 1KB (hyper-efficient)
|
|
func (w *WitnessProof) IsLightweight() bool {
|
|
return w.Size() < 1024
|
|
}
|
|
|
|
// CompressedWitness creates an even smaller witness for network transmission
|
|
type CompressedWitness struct {
|
|
CommitmentAndProof []byte // Combined commitment + proof
|
|
Metadata uint64 // Packed height + timestamp
|
|
Validators uint32 // Validator bitfield (up to 32 validators)
|
|
}
|
|
|
|
// Compress creates a compressed witness (< 200 bytes)
|
|
func (w *WitnessProof) Compress() *CompressedWitness {
|
|
// Combine commitment and opening proof
|
|
combined := append(w.Commitment[:16], w.OpeningProof[:16]...)
|
|
|
|
// Pack metadata
|
|
metadata := (w.BlockHeight << 32) | (w.Timestamp & 0xFFFFFFFF)
|
|
|
|
// Compress validator bits to uint32 (supports up to 32 validators)
|
|
var validatorBits uint32
|
|
for i := 0; i < len(w.CoronaBits) && i < 4; i++ {
|
|
validatorBits |= uint32(w.CoronaBits[i]) << (i * 8)
|
|
}
|
|
|
|
return &CompressedWitness{
|
|
CommitmentAndProof: combined,
|
|
Metadata: metadata,
|
|
Validators: validatorBits,
|
|
}
|
|
}
|
|
|
|
// Size returns size of compressed witness
|
|
func (c *CompressedWitness) Size() int {
|
|
return len(c.CommitmentAndProof) + 8 + 4 // ~44 bytes
|
|
}
|
|
|
|
// validateCompressedStructure checks structural validity of a compressed witness.
|
|
// This is NOT cryptographic verification -- it only checks that the validator
|
|
// threshold is met in the bitfield. Callers must not treat a nil return as
|
|
// proof of authenticity.
|
|
func (v *VerkleWitness) validateCompressedStructure(cw *CompressedWitness) error {
|
|
// Extract validator count from bitfield
|
|
validatorCount := 0
|
|
for i := uint32(0); i < 32; i++ {
|
|
if cw.Validators&(1<<i) != 0 {
|
|
validatorCount++
|
|
}
|
|
}
|
|
|
|
// Check threshold (assuming PQ finality)
|
|
if validatorCount < v.minThreshold {
|
|
return errors.New("insufficient validators")
|
|
}
|
|
|
|
// With PQ finality assumption, we're done!
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// SP 800-185 helpers (local copies — keep witness.go reviewable in
|
|
// isolation; identical to the ones in round_digest.go / kmac256.go).
|
|
// =============================================================================
|
|
|
|
func u64BEWitness(v uint64) []byte {
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], v)
|
|
return b[:]
|
|
}
|
|
|
|
func encodeStringSP800185Witness(s []byte) []byte {
|
|
out := leftEncodeSP800185Witness(uint64(len(s)) * 8)
|
|
out = append(out, s...)
|
|
return out
|
|
}
|
|
|
|
func leftEncodeSP800185Witness(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
|
|
}
|
|
|
|
func rightEncodeSP800185Witness(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
|
|
}
|