Files
consensus/pkg/rust/tests/cert_conformance.rs
zeekayandHanzo Dev ac10e53f84 cert: give the export rung a floor on the SET, so a shrinking denominator cannot shrink past a committee
Reading both floors over the signers let a set shrink to its signers, and a rung
whose floors are read over n is satisfiable at every n. TwoThirdsCount(1) is 1, so
one signature over a one-signer set clears the count floor and the stake floor
together and mints export finality. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is
ZERO for n of one, two and three: such a certificate absorbs no fault at all, and
one compromised key among its signers forges it outright.

So the export rung refuses a signing set below minBFTCommittee — the same constant
bftCommittee floors committee SELECTION at, read at the certificate instead of at
the sampler, and reused rather than restated. It is a floor on the SET, stated
beside the n<1 refusal because neither quorum floor implies it: both are read over
n and both shrink with it. Nova has no such clause and must not grow one — it
authorizes local execution the chain can still reorg away, so a small or
partitioned chain has to be able to make progress. A three-validator chain now
PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any fault
tolerance. Rust mirrors it as CertError::MinCommittee, which the corpus vocabulary
folds into Go's one belowThreshold class.

The transcription guard was reading the wrong denominator. verdict_test computed
n as len(c.Set), the membership ROLL, so a floor moved onto the roll would agree
with the guard and be re-blessed by -update — the second line of defence agreeing
with the regression it exists to catch. It now counts the seats that hold a key,
on the Quasar branch and the Nova one, and transcribes the committee floor as a
search for the least n whose fault budget reaches one rather than as the constant.
Proven by construction: with the corpus source reporting the roll as its signer
count and the golden re-blessed, the old guard passes and the new one fails.

The corpus could not have caught it either. quasar_keyless_third had three signers
on a roll of four, and ⌊2·3/3⌋+1 and ⌊2·4/3⌋+1 are both three, so its count half
was vacuous. Two rows now separate the denominators one at a time:
quasar_keyless_stake (six signers, a seventh member holding a third of the roll —
the count floor is five either way, so only stake decides) and
quasar_keyless_count (four signers, two members holding one unit each — the stake
clears under either reading, so only the count decides). TestKeylessRowsBind-
BothDenominators fails if either is lost or flattened.

quasar_keyless_third itself FLIPS to a refusal, and it is the row that states the
new clause: the recorded stake floor is 200 over the three hundred that can sign
and not 333 over the five hundred the chain carries, so the denominator IS the
signers — and the certificate is refused anyway, on neither quorum floor. Reading
the floors over the signers stops a spectator stranding a chain; it is not a way
for a chain with three signers to export. Every other frozen verdict is
byte-identical, and the admission section and the signed vectors are untouched.

StakeSource gains CarriedStake, and no floor may read it. SignerStake alone
decides a certificate correctly and cannot show the set shrinking underneath one:
a chain whose signable stake has fallen to a sliver of what it carries still
certifies at ⅔ of the sliver, and ResponsiveStakePct — voted over signer — stays
at one the whole way down. FinalityStatus.SignerStakePct is the number that falls,
and the one to alarm on. Rust has carried(), C++ has total_stake(); Go had lost
the projection when the denominator moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 20:52:28 -07:00

260 lines
12 KiB
Rust

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! Signatures made by Go, checked by Rust.
//!
//! `tests/cert.rs` proves this crate is self-consistent: it signs and then
//! accepts its own signatures. That would hold just as well if the ciphersuite,
//! the domain tag or the point encoding were wrong, so long as they were
//! wrong the same way twice.
//!
//! The vectors here were produced by the Go implementation the network runs —
//! `luxfi/crypto/bls` signing `chain.CanonicalVoteMessage` — so they fail if
//! this crate disagrees with it about the domain separation tag, about which
//! group holds the key and which holds the signature, or about compressed point
//! encoding. It is the check that a Rust node and a Go node accept each other's
//! votes rather than each other's shape.
//!
//! Regenerate with:
//!
//! ```text
//! cd ~/work/lux/consensus && mkdir -p zz_vec && cat > zz_vec/main.go <<'EOF'
//! package main
//! import ("encoding/hex"; "fmt"
//! "github.com/luxfi/consensus/engine/chain"
//! "github.com/luxfi/crypto/bls"; "github.com/luxfi/ids")
//! func fill(b byte) ids.ID { var i ids.ID; for k := range i { i[k] = b }; return i }
//! func main() {
//! pos := chain.VotePosition{ChainID: fill(7), Height: 42, Round: 3,
//! BlockID: fill(11), ParentID: fill(10), CanonicalID: fill(12),
//! ParentCanonicalID: fill(13), ExecutionStateRoot: fill(14),
//! PayloadRoot: fill(15), ValidatorSetRoot: fill(16)}
//! msg := chain.CanonicalVoteMessage(pos)
//! fmt.Println(hex.EncodeToString(msg))
//! for n := 1; n <= 3; n++ {
//! seed := make([]byte, 32); for i := range seed { seed[i] = byte(n) }
//! sk, _ := bls.SecretKeyFromSeed(seed); sig, _ := sk.Sign(msg)
//! fmt.Println(hex.EncodeToString(bls.PublicKeyToCompressedBytes(sk.PublicKey())),
//! hex.EncodeToString(bls.SignatureToBytes(sig)))
//! }
//! }
//! EOF
//! go run ./zz_vec && rm -rf zz_vec
//! ```
use blst::min_pk::SecretKey;
use lux_consensus::cert::{NodeId, QuorumCert, ValidatorSet, Vote, VoteVerifier};
use lux_consensus::finality::{canonical_vote_message, Finality, Position};
use lux_consensus::pop;
/// The position the Go program signed over.
fn position() -> Position {
let f = |b: u8| [b; 32];
Position {
chain_id: f(7),
height: 42,
round: 3,
block_id: f(11),
parent_id: f(10),
canonical_id: f(12),
parent_canonical_id: f(13),
execution_state_root: f(14),
payload_root: f(15),
validator_set_root: f(16),
}
}
/// The exact bytes Go's `chain.CanonicalVoteMessage` produced for that position.
const GO_MESSAGE: &str = "4c55582f636861696e2f766f74652f7632000003010707070707070707070707070707070707070707070707070707070707070707000000000000002a000000030c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f101010101010101010101010101010101010101010101010101010101010101001";
/// Compressed public key and signature per signer, from `SecretKeyFromSeed([n; 32])`.
const GO_SIGNERS: [(&str, &str); 3] = [
(
"95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
"a0888686632c21923517b3cc8c9aa5f7f75b17a952bbe540fc967d3aa6356cd6007bdb5f48a3f4819808bda3d96560520753348e22064400ecc7758c711b22a7985d2e9bcc0a43c019d27d99d07af9eba17328e0552ff2d3bf25470af7b81c58",
),
(
"ac80a5e08c712d5f08f0306ad743f7d8c215d982489b84a1d6ba805733d94c006e8938f9089a75db3ffa135af33bc69a",
"889a66f016ec61fe21619024b57ec71b48ff213a4a4012e1823f5ce15513ccf37c1d98c2593e8e4056a75061312d5c2f16662ddc6b2c7d8a7947887414f3c0dae32081624357e41ca3ac44ca1ca9cb2f49542dc308fd395a406332f8105a003d",
),
(
"96df714a5cc9ddd2298546dce3d6d3827762a6d5b1c2a91e5ca93c9c898b1b4319cc105c493212a55b63080732ec2249",
"867e94ed647199c69f59adb13464243f7700bab7208709bc9e5f5ff9641e91fa19fda070b34ff6613a584f2c38ecb5801394e97bf92e790b825f5771318203ea7a817c908fc9fb80fddf0076c50225e8997103b5eb15b900c3769d9e25fe2a7d",
),
];
/// A validator identity: 20 bytes, the width Go names a node by. The node id
/// enters no signed message, so these vectors are unchanged by its width — the
/// Go message and the Go signatures below are the same bytes they always were.
fn node_id(n: u8) -> NodeId {
let mut id = [0u8; 20];
id[0] = n;
id
}
/// Ids ascending, so the assembled certificate is already canonically ordered.
fn go_set() -> (ValidatorSet, Vec<Vote>) {
let message = canonical_vote_message(&position(), true);
let mut set = ValidatorSet::new();
let mut votes = Vec::new();
for (i, (pk, sig)) in GO_SIGNERS.iter().enumerate() {
let id = node_id(i as u8 + 1);
let pk_bytes = hex::decode(pk).unwrap();
// Go generated these from `SecretKeyFromSeed([n; 32])` under cgo, which
// is `blst.KeyGen(seed, info=nil)` with the standard keygen salt —
// byte-identical to this crate's `key_gen`. Regenerating the key from the
// seed and getting the same public key confirms that cgo path and yields
// the secret needed to form the proof of possession registration now
// requires. (The non-cgo build of `luxfi/crypto` routes through CIRCL
// with an empty HKDF salt and would derive a different key; these vectors
// pin the cgo path, which is what Lux ships.)
let seed = [i as u8 + 1; 32];
let sk = SecretKey::key_gen(&seed, &[]).expect("key_gen");
assert_eq!(
sk.sk_to_pk().compress().to_vec(),
pk_bytes,
"signer {i}: Go's SecretKeyFromSeed and this crate's key_gen disagree on the seed",
);
let proof = pop::sign(&sk, &id, &pk_bytes);
set.insert(id, 100, &pk_bytes, &proof)
.expect("Go public key rejected at registration");
let signature = hex::decode(sig).unwrap();
assert!(
set.verify_vote(&id, &message, &signature, 0),
"signer {i}: Go's signature did not verify here",
);
votes.push(Vote {
node_id: id,
accept: true,
signature,
});
}
(set, votes)
}
/// The message this crate builds is the message Go signed, byte for byte. If
/// this fails nothing below can pass, and the failure is in the encoding rather
/// than the cryptography.
#[test]
fn the_message_is_the_one_go_signed() {
assert_eq!(
hex::encode(canonical_vote_message(&position(), true)),
GO_MESSAGE,
);
}
/// A Go public key is a valid group element here, and a Go signature over the
/// Go message verifies under it. This pins the ciphersuite: the tag
/// `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`, the key in G1 compressed to
/// 48 bytes, the signature in G2 compressed to 96.
#[test]
fn go_signatures_verify_here() {
let (_set, votes) = go_set();
assert_eq!(votes.len(), 3);
}
/// The whole predicate over a certificate whose every signature was made by the
/// Go implementation. This is a Rust node accepting a block that Go validators
/// certified.
///
/// The frozen vectors are three signers, and three signers are not a Byzantine
/// committee — f = (3-1)/3 is 0 — so the export rung refuses that set on its own
/// terms, in Go and here alike. The vectors pin the CIPHERSUITE, not the size of
/// a deployment, so the set they are weighed against gains a fourth validator
/// that holds a key, holds stake and does not sign. Not one byte of the Go
/// material moves: the same three signatures over the same message decide a real
/// export certificate, three of four, which is what a Rust node checking a Go
/// network's certificate actually does.
#[test]
fn a_go_signed_certificate_is_accepted() {
let (mut set, votes) = go_set();
// Three of three is refused on the committee clause, not on either quorum
// floor: 300 of 300 clears floor(2·300/3) = 200 and three signers clear a
// count floor of three. The size is the thing that is wrong.
let three = QuorumCert::assemble(Finality::Quasar, position(), 3, &votes).expect("assemble");
assert_eq!(three.verify(&set, 0), Ok(()));
assert!(
three.verify_weighted(&set, &set, 0).is_err(),
"an export certificate over three signers has no Byzantine fault budget"
);
// A fourth validator, keyed and staked, that casts no vote. The certificate
// is unchanged.
let sk = SecretKey::key_gen(&[9u8; 32], &[]).expect("key_gen");
let pk = sk.sk_to_pk().compress();
let silent = node_id(9);
set.insert(silent, 100, &pk, &pop::sign(&sk, &silent, &pk))
.expect("a fourth validator");
// Three of four: the count floor is three, and 300 of 400 staked is strictly
// above floor(2·400/3) = 266.
assert_eq!(three.verify(&set, 0), Ok(()));
assert_eq!(three.verify_weighted(&set, &set, 0), Ok(()));
}
/// Go's signatures are checked one at a time here, because that is the only
/// form Go produces: `engine/chain/cert.go` — "there is no aggregate field,
/// because nothing is aggregated". A Rust node that accepted an aggregate would
/// be accepting evidence this network cannot emit, under a rule Go cannot check.
///
/// And each signature is bound to this exact message: one bit elsewhere and it
/// is not a proof.
#[test]
fn each_go_signature_is_checked_on_its_own() {
let (set, votes) = go_set();
let message = canonical_vote_message(&position(), true);
for v in &votes {
assert!(set.verify_vote(&v.node_id, &message, &v.signature, 0));
}
let mut other = message.clone();
other[225] ^= 0x01; // the accept byte
for v in &votes {
assert!(!set.verify_vote(&v.node_id, &other, &v.signature, 0));
}
}
/// A Go signature is bound to the position Go signed it over. Presenting it
/// under any other position fails, so a Rust node cannot be made to accept a
/// block by replaying real votes from a different one.
#[test]
fn a_go_signature_does_not_travel_to_another_position() {
let (set, votes) = go_set();
let mut elsewhere = position();
elsewhere.height += 1;
let cert = QuorumCert::assemble(Finality::Quasar, elsewhere, 3, &votes).expect("assemble");
assert!(cert.verify(&set, 0).is_err());
}
/// The domain tag itself, frozen against the network's own constant.
///
/// Every other test in this file proves conformance *empirically*: a signature
/// Go made verifies here. That check is only as good as the vectors, and the
/// header above says how to regenerate them. Change `DST` and rerun that
/// recipe and the whole file goes green again — Go and Rust would agree with
/// each other under a tag the running network never signs under, and a Rust
/// node would reject every real vote on the wire.
///
/// So the tag is pinned as a literal too. This is `dstSignature` in
/// `luxfi/crypto/bls/bls.go`, the value the live network signs consensus votes
/// under. Note the suffix: `_NUL_`, not `_POP_`. `luxfi/crypto/bls` also
/// declares `CiphersuiteSignature = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"`,
/// which is neither the signature tag nor the proof-of-possession tag
/// (`BLS_POP_..._POP_`). Nothing signs under it today — the `Ciphersuite` type
/// has no users outside its own file — but it is a wrong constant sitting next
/// to the right one, and this is the assertion that catches anyone who copies it.
#[test]
fn the_domain_tag_is_the_one_the_network_signs_under() {
assert_eq!(
lux_consensus::cert::DST,
b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_",
"the vote ciphersuite must be luxfi/crypto/bls dstSignature, byte for byte"
);
}