One proof implementation, over whichever message the caller has

A node-bound proof is one message among several: a committee entitles a
validator ON A CHAIN and signs the chain into its proof, so it has a
message of its own. It could only reach the same encoding and
possession clauses by restating them, and a second statement of what a
canonical key is is a second answer waiting to differ.

The general pair is the implementation now and the node-bound pair is
it with one message, so a registration and a committee are checked by
the same rules or by none. Admission is the same door for the same
reason.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-08 09:53:52 -07:00
co-authored by Hanzo Dev
parent 937578257d
commit c98bbf4e9a
2 changed files with 165 additions and 26 deletions
+88 -20
View File
@@ -102,26 +102,54 @@ pub struct Vote {
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CertError {
Version { got: u16, want: u16 },
Type { got: u8, want: u8 },
Version {
got: u16,
want: u16,
},
Type {
got: u8,
want: u8,
},
UnknownTier(Finality),
ThresholdZero,
NoVotes,
NotStrictlyIncreasing(usize),
VoteNotAccept(usize),
SigInvalid(usize),
BelowThreshold { have: u32, need: u32 },
UnresolvedSet { n: i64 },
BelowThreshold {
have: u32,
need: u32,
},
UnresolvedSet {
n: i64,
},
/// An EXPORT certificate over a signing set too small for a Byzantine
/// supermajority to mean anything: f = ⌊(n−1)/3⌋ is zero below
/// [`crate::finality::MIN_BFT_COMMITTEE`], so the certificate absorbs no fault
/// and one compromised key forges it. A floor on the SET, not on the voters —
/// Go folds it into `ErrQCBelowThreshold`.
MinCommittee { n: i64, need: i64 },
SignerFloor { have: i64, need: i64, n: i64 },
StakeZero { epoch_height: u64 },
StakeBelowMajority { voted: u64, signer: u64, need_above: u64 },
StakeBelowSupermajority { voted: u64, signer: u64, need_above: u64 },
MinCommittee {
n: i64,
need: i64,
},
SignerFloor {
have: i64,
need: i64,
n: i64,
},
StakeZero {
epoch_height: u64,
},
StakeBelowMajority {
voted: u64,
signer: u64,
need_above: u64,
},
StakeBelowSupermajority {
voted: u64,
signer: u64,
need_above: u64,
},
/// A public key that does not decode to a non-identity point of the right
/// subgroup.
KeyEncoding,
@@ -152,7 +180,11 @@ pub enum CertError {
/// not require, and tolerating it would let a certificate redefine the rung
/// upward exactly as tolerating an under-claim lets it redefine the rung down.
/// Neither direction is the set's number.
ThresholdNotDerived { declared: u32, derived: i64, n: i64 },
ThresholdNotDerived {
declared: u32,
derived: i64,
n: i64,
},
/// The set's weights sum past what a `u64` can hold. Go's
/// `ErrWeightOverflow`, and refused for the reason Go refuses it: every
/// threshold in this crate is read against the total, so a total that
@@ -197,11 +229,22 @@ impl std::fmt::Display for CertError {
CertError::StakeZero { epoch_height } => {
write!(f, "total stake is zero at epoch height {epoch_height}")
}
CertError::StakeBelowMajority { voted, signer, need_above } => {
CertError::StakeBelowMajority {
voted,
signer,
need_above,
} => {
write!(f, "nova voted={voted} signer={signer}, need > {need_above}")
}
CertError::StakeBelowSupermajority { voted, signer, need_above } => {
write!(f, "quasar voted={voted} signer={signer}, need > {need_above}")
CertError::StakeBelowSupermajority {
voted,
signer,
need_above,
} => {
write!(
f,
"quasar voted={voted} signer={signer}, need > {need_above}"
)
}
CertError::KeyEncoding => write!(f, "public key does not decode to a valid point"),
CertError::NoKey => write!(f, "registration carries no public key"),
@@ -209,7 +252,11 @@ impl std::fmt::Display for CertError {
CertError::DuplicateKey => write!(f, "public key is registered to more than one node"),
CertError::DuplicateNode => write!(f, "node is registered more than once"),
CertError::PopInvalid => write!(f, "proof of possession does not verify for this key"),
CertError::ThresholdNotDerived { declared, derived, n } => write!(
CertError::ThresholdNotDerived {
declared,
derived,
n,
} => write!(
f,
"cert declares threshold {declared}, a set of {n} signers derives {derived}"
),
@@ -358,11 +405,7 @@ impl QuorumCert {
/// 5. every vote is an ACCEPT
/// 6. every signature verifies over the certificate's own position
/// 7. the count of such votes meets the threshold
pub fn verify(
&self,
verifier: &dyn VoteVerifier,
epoch_height: u64,
) -> Result<(), CertError> {
pub fn verify(&self, verifier: &dyn VoteVerifier, epoch_height: u64) -> Result<(), CertError> {
if self.version != QUORUM_CERT_VERSION {
return Err(CertError::Version {
got: self.version,
@@ -799,6 +842,31 @@ impl ValidatorSet {
weight: u64,
public_key: &[u8],
proof: &[u8],
) -> Result<(), CertError> {
self.insert_over(
node,
weight,
public_key,
&pop::message(&node, public_key),
proof,
)
}
/// Admit a member whose proof is over a message of the caller's choosing.
///
/// [`insert`] is this with the node-bound message, which is the only one a
/// registration uses. A committee entitles a validator ON A CHAIN and signs
/// the chain into its proof, so it has a message of its own — and every
/// other clause of admission, down to which duplicate is refused, must
/// still be the same clause. Two admission doors would be two answers to
/// who is in the set.
pub fn insert_over(
&mut self,
node: NodeId,
weight: u64,
public_key: &[u8],
message: &[u8],
proof: &[u8],
) -> Result<(), CertError> {
// NO KEY. Not a malformed key — no key. A validator with no key cannot
// sign, so it cannot come through the proof path at all.
@@ -816,7 +884,7 @@ impl ValidatorSet {
// and it is the SAME function the Go oracle's frozen vectors pin. There
// is one proof-of-possession implementation in this crate; registration
// calls it rather than restating it, so the two cannot drift.
pop::verify(&node, public_key, proof).map_err(|e| match e {
pop::verify_over(message, public_key, proof).map_err(|e| match e {
PopError::Key => CertError::KeyEncoding,
PopError::Proof | PopError::Possession => CertError::PopInvalid,
})?;
+77 -6
View File
@@ -71,6 +71,17 @@ pub fn message(node: &NodeId, key: &[u8]) -> Vec<u8> {
/// Verify a node-bound proof of possession, in the order the standard fixes:
/// encoding, then possession. A port of the Go oracle's `pop.Verify`.
pub fn verify(node: &NodeId, key: &[u8], proof: &[u8]) -> Result<(), PopError> {
verify_over(&message(node, key), key, proof)
}
/// Verify a proof over ANY message, under the same two clauses in the same
/// order: the encodings first, then the possession.
///
/// A node-bound proof is one message among several. A committee entitles a
/// validator ON A CHAIN and signs the chain with it; a caller with a message
/// of its own would otherwise restate these clauses, and a second statement of
/// what a canonical key is is a second answer waiting to differ from this one.
pub fn verify_over(message: &[u8], key: &[u8], proof: &[u8]) -> Result<(), PopError> {
if key.len() != KEY_LEN {
return Err(PopError::Key);
}
@@ -91,8 +102,7 @@ pub fn verify(node: &NodeId, key: &[u8], proof: &[u8]) -> Result<(), PopError> {
// here so an identity or non-subgroup proof is refused as a bad PROOF — the
// encoding clause — exactly as the oracle does, not later as a failed pairing.
sig.validate(true).map_err(|_| PopError::Proof)?;
let msg = message(node, key);
if sig.verify(true, &msg, POP_DST, &[], &pk, true) != BLST_ERROR::BLST_SUCCESS {
if sig.verify(true, message, POP_DST, &[], &pk, true) != BLST_ERROR::BLST_SUCCESS {
return Err(PopError::Possession);
}
Ok(())
@@ -101,8 +111,69 @@ pub fn verify(node: &NodeId, key: &[u8], proof: &[u8]) -> Result<(), PopError> {
/// Produce a node-bound proof for `node` under `secret`. The signing side of
/// [`verify`]; used by registrants and by tests.
pub fn sign(secret: &blst::min_pk::SecretKey, node: &NodeId, key: &[u8]) -> Vec<u8> {
secret
.sign(&message(node, key), POP_DST, &[])
.compress()
.to_vec()
sign_over(secret, &message(node, key))
}
/// Produce a proof over ANY message, under the same domain tag. The signing
/// side of [`verify_over`], and the one place the tag is applied.
pub fn sign_over(secret: &blst::min_pk::SecretKey, message: &[u8]) -> Vec<u8> {
secret.sign(message, POP_DST, &[]).compress().to_vec()
}
#[cfg(test)]
mod general_message_tests {
use super::*;
fn keypair() -> (blst::min_pk::SecretKey, Vec<u8>) {
let sk = blst::min_pk::SecretKey::key_gen(&[7u8; 32], &[]).expect("key");
let pk = sk.sk_to_pk().compress().to_vec();
(sk, pk)
}
/// The node-bound pair is the general pair with one message. If these ever
/// disagree, a registration and a committee are checking different rules.
#[test]
fn the_node_bound_proof_is_the_general_one_over_its_own_message() {
let (sk, pk) = keypair();
let node: NodeId = [3u8; NODE_LEN];
let bound = sign(&sk, &node, &pk);
let general = sign_over(&sk, &message(&node, &pk));
assert_eq!(bound, general, "one proof, written two ways");
assert!(verify(&node, &pk, &general).is_ok());
assert!(verify_over(&message(&node, &pk), &pk, &bound).is_ok());
}
/// A proof for one message does not hold over another — which is the whole
/// reason a committee signs the chain it entitles a validator on.
#[test]
fn a_proof_does_not_carry_to_another_message() {
let (sk, pk) = keypair();
let node: NodeId = [3u8; NODE_LEN];
let here = [b"chain-a".as_slice(), &node, &pk].concat();
let elsewhere = [b"chain-b".as_slice(), &node, &pk].concat();
let proof = sign_over(&sk, &here);
assert!(verify_over(&here, &pk, &proof).is_ok());
assert_eq!(
verify_over(&elsewhere, &pk, &proof),
Err(PopError::Possession),
"a proof made for one chain entitles nothing on another"
);
}
/// The encoding clauses are the same clauses, in the same order: a bad key
/// is a bad key before any pairing runs.
#[test]
fn the_encoding_clauses_come_first_either_way() {
let (sk, pk) = keypair();
let msg = b"anything".as_slice();
let proof = sign_over(&sk, msg);
assert_eq!(
verify_over(msg, &pk[..KEY_LEN - 1], &proof),
Err(PopError::Key)
);
assert_eq!(
verify_over(msg, &pk, &proof[..PROOF_LEN - 1]),
Err(PopError::Proof)
);
}
}