mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
260 lines
12 KiB
Rust
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"
|
|
);
|
|
}
|