mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
cert: prove possession and pin one key to one validator (eligibility)
1.22.2 killed the forged certificate but secured only verification, not
registration — and that is where the CTO's bar, "unique signers + PoP," lives. A
red review confirmed two registration attacks with running PoCs:
- one secret key registered under many node ids clears nova_signer_floor, which
was counting ids and not signers — a stake-majority holder self-finalizes
Nova alone, the exact fork class the floor exists to prevent;
- an honest validator's PUBLISHED key can be registered by a third party and
its genuine ACCEPT replayed, because key_validate proves the point is on the
curve and nothing about who holds the secret.
ValidatorSet::insert now takes a proof of possession and enforces both halves:
- a public key belongs to at most one node (an `owner` index), so a floor on
distinct voters is a floor on distinct signers;
- the registrant signs its own (node ‖ key) under a POP-specific DST
(BLS_POP_..._POP_, distinct from the vote DST), verified against the key at
registration. A key the registrant does not hold — an honest validator's, or
a g1·x − Σpk cancelling key — cannot be registered in its name, because the
proof cannot be made. remove and insert_unkeyed free the key so rotation is
remove-then-insert and a retired key does not stay live.
Two more from the same review:
- HIGH: finality was keyed on the transport block_id, which is unsigned, so a
verified certificate could be relabelled to a block it never attested.
Position::signed_identity returns the value the votes actually committed to
(the canonical id, or block_id only in the degrade where none is set), and
the engine keys finality on that. Two positions that sign to the same bytes
now resolve to one entry, not two.
- MED: verify()'s doc claimed it was "the whole rule on an equal-stake chain."
It is not — its threshold is a field of the certificate, so alone it clears a
1-of-n cert that declares threshold 1. The doc now says so, and names
verify_weighted as the accept rule.
The rogue-key suite is reframed to the stronger truth: the rogue is refused at
REGISTRATION now, one step before the per-signature backstop. cert_conformance
regenerates each Go key from its seed to form the proof, which also asserts Go's
SecretKeyFromSeed and this crate's key_gen derive one key from one seed.
This is a BREAKING registration API (insert and add_validator_with_key take a
proof), so it is 1.22.3 and NOT published — the published 1.22.2 stays the
non-PoP code, no version shares two codebases. Rolling PoP to the Go oracle and
the C++ port is a coordinated, fork-class change and the CTO's call; this branch
does not touch mainnet Go. The proof-of-possession scheme is stated exactly so
Go and C++ can implement it byte-identically: message = node(32) ‖ pubkey(48),
ciphersuite BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_, min_pk (pk G1, sig G2).
Still open before validator-eligible: the Go/C++ PoP rollout, a wire codec with
full-vector conformance, and the Quasar signer-floor decision (unchanged here,
to stay faithful to the Go oracle).
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Generated
+1
-1
@@ -360,7 +360,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "lux-consensus"
|
||||
version = "1.22.2"
|
||||
version = "1.22.3"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"blst",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lux-consensus"
|
||||
version = "1.22.2"
|
||||
version = "1.22.3"
|
||||
edition = "2021"
|
||||
description = "Lux Consensus SDK — pure-Rust Quasar post-quantum probabilistic consensus (Wave / FPC / Photon / Focus / Quasar PQ finality via hybrid BLS12-381 + Pulsar = P3Q)"
|
||||
license-file = "LICENSE"
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::hint::black_box;
|
||||
use blst::min_pk::SecretKey;
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
|
||||
use lux_consensus::cert::{QuorumCert, ValidatorSet, Vote, DST};
|
||||
use lux_consensus::cert::{pop_message, QuorumCert, ValidatorSet, Vote, DST, POP_DST};
|
||||
use lux_consensus::finality::{canonical_vote_message, Finality, Id, Position};
|
||||
use lux_consensus::{sha256, Engine, FpcSelector, QuasarConfig, QuasarEngine, VoteType};
|
||||
|
||||
@@ -51,7 +51,11 @@ fn certified(n: usize) -> (ValidatorSet, QuorumCert) {
|
||||
ikm[..8].copy_from_slice(&(i as u64 + 1).to_be_bytes());
|
||||
let sk = SecretKey::key_gen(&ikm, &[]).expect("key_gen");
|
||||
let id = node_id(i);
|
||||
set.insert(id, 100, &sk.sk_to_pk().compress()).expect("insert");
|
||||
let pop = sk
|
||||
.sign(&pop_message(&id, &sk.sk_to_pk().compress()), POP_DST, &[])
|
||||
.compress()
|
||||
.to_vec();
|
||||
set.insert(id, 100, &sk.sk_to_pk().compress(), &pop).expect("insert");
|
||||
votes.push(Vote {
|
||||
node_id: id,
|
||||
accept: true,
|
||||
|
||||
+103
-19
@@ -43,6 +43,23 @@ use crate::finality::{
|
||||
/// which is blst's `min_pk`.
|
||||
pub const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||
|
||||
/// The ciphersuite a validator proves possession of its secret under. Distinct
|
||||
/// from [`DST`] by the `_POP_` tag, so a proof of possession is not a vote and a
|
||||
/// vote is not a proof of possession — the two live in separate signature
|
||||
/// spaces and neither can be replayed as the other.
|
||||
pub const POP_DST: &[u8] = b"BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_";
|
||||
|
||||
/// The message a proof of possession signs: the node id it registers, then the
|
||||
/// compressed public key, in that order. Binding the id as well as the key means
|
||||
/// a proof made for one (node, key) pair proves nothing for any other, so a
|
||||
/// published key cannot be adopted under a second identity.
|
||||
pub fn pop_message(node: &Id, public_key: &[u8]) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(node.len() + public_key.len());
|
||||
m.extend_from_slice(node);
|
||||
m.extend_from_slice(public_key);
|
||||
m
|
||||
}
|
||||
|
||||
/// A compressed BLS public key, in bytes.
|
||||
pub const PUBLIC_KEY_LEN: usize = 48;
|
||||
|
||||
@@ -80,6 +97,15 @@ pub enum CertError {
|
||||
StakeZero { epoch_height: u64 },
|
||||
StakeBelowMajority { voted: u64, total: u64, need_above: u64 },
|
||||
StakeBelowSupermajority { voted: u64, total: u64, need_above: u64 },
|
||||
/// A public key that does not decode to a non-identity point of the right
|
||||
/// subgroup.
|
||||
KeyEncoding,
|
||||
/// A public key already registered to a different node. A key belongs to at
|
||||
/// most one validator, so counting distinct voters counts distinct keys.
|
||||
DuplicateKey,
|
||||
/// A proof of possession that is not a valid signature by this key over its
|
||||
/// own (node, key) message under [`POP_DST`].
|
||||
PopInvalid,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CertError {
|
||||
@@ -113,6 +139,9 @@ impl std::fmt::Display for CertError {
|
||||
CertError::StakeBelowSupermajority { voted, total, need_above } => {
|
||||
write!(f, "quasar voted={voted} total={total}, need > {need_above}")
|
||||
}
|
||||
CertError::KeyEncoding => write!(f, "public key does not decode to a valid point"),
|
||||
CertError::DuplicateKey => write!(f, "public key is already registered to another node"),
|
||||
CertError::PopInvalid => write!(f, "proof of possession does not verify for this key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,9 +230,14 @@ impl QuorumCert {
|
||||
/// The count-only predicate: every structural clause, plus a valid signature
|
||||
/// from each of at least `threshold` distinct voters.
|
||||
///
|
||||
/// This is the whole rule on an equal-stake chain. Where weights differ,
|
||||
/// [`Self::verify_weighted`] adds the stake floor, and a count alone is not
|
||||
/// an accept.
|
||||
/// This is NOT a standalone accept rule, and must not be used as one. The
|
||||
/// `threshold` it counts against is a field of the certificate — the
|
||||
/// certificate names its own quorum — so on its own this clears a 1-of-n
|
||||
/// certificate that declares `threshold: 1`. It is a building block:
|
||||
/// [`Self::verify_weighted`] is the accept rule, recomputing the floor from
|
||||
/// the live stake set so a certificate cannot declare its own quorum. Call
|
||||
/// `verify` directly only where the caller itself supplies and checks the
|
||||
/// admissible threshold against the set — never as the whole of accept.
|
||||
///
|
||||
/// Clauses, in the order Go checks them:
|
||||
///
|
||||
@@ -407,6 +441,11 @@ impl QuorumCert {
|
||||
pub struct ValidatorSet {
|
||||
keys: HashMap<Id, PublicKey>,
|
||||
weights: HashMap<Id, u64>,
|
||||
/// The node each public key belongs to, so a key registered to one validator
|
||||
/// cannot be registered to a second. Without it, `nova_signer_floor` bounds
|
||||
/// distinct node ids and not distinct signers, and one secret key registered
|
||||
/// under many ids clears a floor written to require many.
|
||||
owner: HashMap<[u8; PUBLIC_KEY_LEN], Id>,
|
||||
}
|
||||
|
||||
impl ValidatorSet {
|
||||
@@ -414,39 +453,84 @@ impl ValidatorSet {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a validator with the key it signs with.
|
||||
/// Register a validator with the key it signs with, and its proof that it
|
||||
/// holds the matching secret.
|
||||
///
|
||||
/// The key is decompressed and group-checked once, here, so an invalid key
|
||||
/// is refused at registration rather than at every verification — and a
|
||||
/// rejected key leaves no membership behind.
|
||||
/// Three things are checked, and a registration that fails any of them
|
||||
/// leaves no membership behind:
|
||||
///
|
||||
/// `key_validate` establishes that the bytes decode to a non-identity point
|
||||
/// of the right subgroup. It does NOT establish that the registrant holds
|
||||
/// the matching secret, and no registration here does: a key chosen as
|
||||
/// `g1·x − Σ pk_others` passes every check above. That is harmless while
|
||||
/// every signature is checked against exactly one key, and catastrophic the
|
||||
/// moment any of them are summed — see [`VoteVerifier`] on this type, and
|
||||
/// the note on `ValidatorSet` itself.
|
||||
pub fn insert(&mut self, node: Id, weight: u64, public_key: &[u8]) -> Result<(), BLST_ERROR> {
|
||||
/// 1. the key decodes to a non-identity point of the right subgroup
|
||||
/// (`key_validate`);
|
||||
/// 2. no other node already holds this key — a key belongs to exactly one
|
||||
/// validator, so a floor on distinct voters is a floor on distinct
|
||||
/// signers, not on ids one holder can mint at will;
|
||||
/// 3. `pop` is a valid signature by this key over [`pop_message`] under
|
||||
/// [`POP_DST`] — the registrant proves possession of the secret, so a
|
||||
/// key it does not control (an honest validator's published key, or a
|
||||
/// `g1·x − Σ pk_others` cancelling key) cannot be registered in its name.
|
||||
///
|
||||
/// Re-registering the same (node, key) pair with a fresh proof is allowed and
|
||||
/// updates the weight; registering a node under a different key first frees
|
||||
/// the old one.
|
||||
pub fn insert(
|
||||
&mut self,
|
||||
node: Id,
|
||||
weight: u64,
|
||||
public_key: &[u8],
|
||||
pop: &[u8],
|
||||
) -> Result<(), CertError> {
|
||||
if public_key.len() != PUBLIC_KEY_LEN {
|
||||
return Err(BLST_ERROR::BLST_BAD_ENCODING);
|
||||
return Err(CertError::KeyEncoding);
|
||||
}
|
||||
let pk = PublicKey::key_validate(public_key).map_err(|_| CertError::KeyEncoding)?;
|
||||
|
||||
// A key belongs to one node. Its own node re-registering is fine; any
|
||||
// other node claiming it is refused.
|
||||
let mut key_bytes = [0u8; PUBLIC_KEY_LEN];
|
||||
key_bytes.copy_from_slice(public_key);
|
||||
if let Some(owner) = self.owner.get(&key_bytes) {
|
||||
if *owner != node {
|
||||
return Err(CertError::DuplicateKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Possession: a signature this key alone could make, over its own
|
||||
// (node, key) message, in the proof-of-possession space.
|
||||
if pop.len() != SIGNATURE_LEN {
|
||||
return Err(CertError::PopInvalid);
|
||||
}
|
||||
let proof = Signature::uncompress(pop).map_err(|_| CertError::PopInvalid)?;
|
||||
let msg = pop_message(&node, public_key);
|
||||
if proof.verify(true, &msg, POP_DST, &[], &pk, true) != BLST_ERROR::BLST_SUCCESS {
|
||||
return Err(CertError::PopInvalid);
|
||||
}
|
||||
|
||||
// If this node had a different key, free it before taking the new one.
|
||||
if let Some(old) = self.keys.get(&node) {
|
||||
self.owner.remove(&old.compress());
|
||||
}
|
||||
let pk = PublicKey::key_validate(public_key)?;
|
||||
self.keys.insert(node, pk);
|
||||
self.weights.insert(node, weight);
|
||||
self.owner.insert(key_bytes, node);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a validator whose signing key this node does not have.
|
||||
///
|
||||
/// It is a member and it holds stake; it cannot sign anything this set will
|
||||
/// accept until a key arrives.
|
||||
/// accept until a key arrives. Any key it held before is revoked, so this
|
||||
/// truly removes the ability to sign rather than leaving a retired key live.
|
||||
pub fn insert_unkeyed(&mut self, node: Id, weight: u64) {
|
||||
if let Some(old) = self.keys.remove(&node) {
|
||||
self.owner.remove(&old.compress());
|
||||
}
|
||||
self.weights.insert(node, weight);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, node: &Id) {
|
||||
self.keys.remove(node);
|
||||
if let Some(old) = self.keys.remove(node) {
|
||||
self.owner.remove(&old.compress());
|
||||
}
|
||||
self.weights.remove(node);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,24 @@ pub struct Position {
|
||||
pub validator_set_root: Id,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
/// The identity a finality index must key on: the value the signature
|
||||
/// actually commits to. It is `canonical_id`, or `block_id` only in the
|
||||
/// degrade where no canonical id is set — exactly the byte
|
||||
/// [`canonical_vote_message`] folds in as the canonical. The transport
|
||||
/// `block_id` is otherwise unsigned, so keying finality on it lets a
|
||||
/// verified certificate be relabelled to a block it never attested; keying
|
||||
/// on this value cannot be, and two positions that sign to the same bytes
|
||||
/// resolve to the same finality entry rather than two.
|
||||
pub fn signed_identity(&self) -> Id {
|
||||
if self.canonical_id == EMPTY {
|
||||
self.block_id
|
||||
} else {
|
||||
self.canonical_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact bytes a validator signs.
|
||||
///
|
||||
/// Layout, big-endian and fixed width throughout:
|
||||
|
||||
+25
-12
@@ -1128,18 +1128,23 @@ pub mod quasar {
|
||||
self.validators.insert_unkeyed(*id.as_bytes(), weight);
|
||||
}
|
||||
|
||||
/// Register a validator with the BLS key it signs with.
|
||||
/// Register a validator with the BLS key it signs with, and its proof
|
||||
/// that it holds the matching secret.
|
||||
///
|
||||
/// An invalid key is refused rather than stored, so a registered key is
|
||||
/// always one a signature can actually be checked against.
|
||||
/// An invalid key, a key already claimed by another validator, or a
|
||||
/// proof of possession that does not verify is refused rather than
|
||||
/// stored — so a registered key is one a signature can be checked
|
||||
/// against AND one the registrant demonstrably controls. The proof
|
||||
/// travels with the validator declaration; this node does not mint it.
|
||||
pub fn add_validator_with_key(
|
||||
&mut self,
|
||||
id: NodeID,
|
||||
weight: u64,
|
||||
bls_pubkey: &[u8],
|
||||
pop: &[u8],
|
||||
) -> Result<()> {
|
||||
self.validators
|
||||
.insert(*id.as_bytes(), weight, bls_pubkey)
|
||||
.insert(*id.as_bytes(), weight, bls_pubkey, pop)
|
||||
.map_err(|e| ConsensusError::CryptoError(format!("{e:?}")))
|
||||
}
|
||||
|
||||
@@ -1209,7 +1214,11 @@ pub mod quasar {
|
||||
return Err(ConsensusError::NoQuorum);
|
||||
}
|
||||
|
||||
let block_id = ID::from(position.block_id);
|
||||
// Key finality on the identity the votes actually signed, never on
|
||||
// the transport block id, which is unsigned: a verified certificate
|
||||
// must not be relabelable to a block it never attested. See
|
||||
// `Position::signed_identity`.
|
||||
let key = ID::from(position.signed_identity());
|
||||
// Assembly sorts and dedups, so the certificate satisfies the
|
||||
// ordering clause by construction.
|
||||
let cert = Certificate::assemble(
|
||||
@@ -1220,7 +1229,7 @@ pub mod quasar {
|
||||
)
|
||||
.map_err(|e| ConsensusError::CryptoError(e.to_string()))?;
|
||||
|
||||
self.finalized.insert(block_id, cert.clone());
|
||||
self.finalized.insert(key, cert.clone());
|
||||
Ok(cert)
|
||||
}
|
||||
|
||||
@@ -1245,14 +1254,18 @@ pub mod quasar {
|
||||
cert.verify_weighted(&self.validators, &self.validators, 0).is_ok()
|
||||
}
|
||||
|
||||
/// Check if a block has quantum finality
|
||||
pub fn is_finalized(&self, block_id: &ID) -> bool {
|
||||
self.finalized.contains_key(block_id)
|
||||
/// Whether the position with this signed identity has quantum finality.
|
||||
/// The argument is `Position::signed_identity` — the value the votes
|
||||
/// committed to, not the transport block id, which finality is never
|
||||
/// keyed on.
|
||||
pub fn is_finalized(&self, signed_identity: &ID) -> bool {
|
||||
self.finalized.contains_key(signed_identity)
|
||||
}
|
||||
|
||||
/// Get certificate for a block
|
||||
pub fn get_certificate(&self, block_id: &ID) -> Option<&Certificate> {
|
||||
self.finalized.get(block_id)
|
||||
/// The certificate for a signed identity (`Position::signed_identity`),
|
||||
/// if one is finalized.
|
||||
pub fn get_certificate(&self, signed_identity: &ID) -> Option<&Certificate> {
|
||||
self.finalized.get(signed_identity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-13
@@ -15,7 +15,8 @@
|
||||
|
||||
use blst::min_pk::SecretKey;
|
||||
use lux_consensus::cert::{
|
||||
CertError, QuorumCert, StakeSource, ValidatorSet, Vote, VoteVerifier, DST, SIGNATURE_LEN,
|
||||
pop_message, CertError, QuorumCert, StakeSource, ValidatorSet, Vote, VoteVerifier, DST,
|
||||
POP_DST, SIGNATURE_LEN,
|
||||
};
|
||||
use lux_consensus::finality::{canonical_vote_message, Finality, Id, Position};
|
||||
|
||||
@@ -44,6 +45,13 @@ impl Signer {
|
||||
self.sk.sign(message, DST, &[]).compress().to_vec()
|
||||
}
|
||||
|
||||
/// The proof of possession this validator presents at registration: a
|
||||
/// signature over its own (node, key) under the proof-of-possession DST.
|
||||
fn pop(&self) -> Vec<u8> {
|
||||
let msg = pop_message(&self.id, &self.public());
|
||||
self.sk.sign(&msg, POP_DST, &[]).compress().to_vec()
|
||||
}
|
||||
|
||||
fn vote(&self, message: &[u8]) -> Vote {
|
||||
Vote {
|
||||
node_id: self.id,
|
||||
@@ -58,7 +66,7 @@ fn committee(n: u8, weight: u64) -> (Vec<Signer>, ValidatorSet) {
|
||||
let signers: Vec<Signer> = (1..=n).map(|i| Signer::new(i, weight)).collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, s.weight, &s.public()).expect("insert");
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
(signers, set)
|
||||
}
|
||||
@@ -281,7 +289,7 @@ fn a_count_quorum_without_the_stake_is_refused() {
|
||||
.collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, s.weight, &s.public()).expect("insert");
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
|
||||
let cert = valid_cert(&signers, 4, 4);
|
||||
@@ -326,7 +334,7 @@ fn zero_total_stake_fails_closed() {
|
||||
let (signers, _) = committee(5, 0);
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, 0, &s.public()).expect("insert");
|
||||
set.insert(s.id, 0, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
let cert = valid_cert(&signers, 4, 4);
|
||||
|
||||
@@ -347,7 +355,7 @@ fn nova_needs_both_the_majority_and_the_signer_floor() {
|
||||
.collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, s.weight, &s.public()).expect("insert");
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop()).expect("insert");
|
||||
}
|
||||
|
||||
let pos = position();
|
||||
@@ -479,11 +487,11 @@ fn every_byte_of_the_message_is_bound() {
|
||||
#[test]
|
||||
fn an_invalid_public_key_is_refused_at_registration() {
|
||||
let mut set = ValidatorSet::new();
|
||||
assert!(set.insert([1u8; 32], 100, &[0u8; 48]).is_err());
|
||||
assert!(set.insert([2u8; 32], 100, &[0xABu8; 48]).is_err());
|
||||
assert!(set.insert([3u8; 32], 100, &[]).is_err());
|
||||
assert!(set.insert([1u8; 32], 100, &[0u8; 48], &[]).is_err());
|
||||
assert!(set.insert([2u8; 32], 100, &[0xABu8; 48], &[]).is_err());
|
||||
assert!(set.insert([3u8; 32], 100, &[], &[]).is_err());
|
||||
// A 96-byte signature offered where a 48-byte key belongs.
|
||||
assert!(set.insert([4u8; 32], 100, &[0u8; 96]).is_err());
|
||||
assert!(set.insert([4u8; 32], 100, &[0u8; 96], &[]).is_err());
|
||||
assert!(set.is_empty());
|
||||
}
|
||||
|
||||
@@ -527,7 +535,7 @@ fn the_engine_issues_certificates_only_from_signatures() {
|
||||
let mut q = QuasarConsensus::new(&config);
|
||||
|
||||
for s in &signers {
|
||||
q.add_validator_with_key(NodeID::from(s.id), s.weight, &s.public())
|
||||
q.add_validator_with_key(NodeID::from(s.id), s.weight, &s.public(), &s.pop())
|
||||
.expect("register");
|
||||
}
|
||||
|
||||
@@ -611,9 +619,9 @@ fn a_keyless_member_cannot_be_certified() {
|
||||
let mut q = QuasarConsensus::new(&config);
|
||||
|
||||
// Two keyed, two not.
|
||||
q.add_validator_with_key(NodeID::from(signers[0].id), 100, &signers[0].public())
|
||||
q.add_validator_with_key(NodeID::from(signers[0].id), 100, &signers[0].public(), &signers[0].pop())
|
||||
.unwrap();
|
||||
q.add_validator_with_key(NodeID::from(signers[1].id), 100, &signers[1].public())
|
||||
q.add_validator_with_key(NodeID::from(signers[1].id), 100, &signers[1].public(), &signers[1].pop())
|
||||
.unwrap();
|
||||
q.add_validator(NodeID::from(signers[2].id), 100);
|
||||
q.add_validator(NodeID::from(signers[3].id), 100);
|
||||
@@ -651,7 +659,7 @@ fn the_engine_refuses_an_invalid_key() {
|
||||
|
||||
let mut q = QuasarConsensus::new(&QuasarConfig::testnet());
|
||||
assert!(q
|
||||
.add_validator_with_key(NodeID::from([1u8; 32]), 100, &[0u8; 48])
|
||||
.add_validator_with_key(NodeID::from([1u8; 32]), 100, &[0u8; 48], &[])
|
||||
.is_err());
|
||||
assert_eq!(q.validator_count(), 0);
|
||||
assert!(!q.is_validator(&NodeID::from([1u8; 32])));
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
//! go run ./zz_vec && rm -rf zz_vec
|
||||
//! ```
|
||||
|
||||
use lux_consensus::cert::{QuorumCert, ValidatorSet, Vote, VoteVerifier};
|
||||
use blst::min_pk::SecretKey;
|
||||
use lux_consensus::cert::{pop_message, QuorumCert, ValidatorSet, Vote, VoteVerifier, POP_DST};
|
||||
use lux_consensus::finality::{canonical_vote_message, Finality, Id, Position};
|
||||
|
||||
/// The position the Go program signed over.
|
||||
@@ -94,7 +95,24 @@ fn go_set() -> (ValidatorSet, Vec<Vote>) {
|
||||
let mut votes = Vec::new();
|
||||
for (i, (pk, sig)) in GO_SIGNERS.iter().enumerate() {
|
||||
let id = node_id(i as u8 + 1);
|
||||
set.insert(id, 100, &hex::decode(pk).unwrap())
|
||||
let pk_bytes = hex::decode(pk).unwrap();
|
||||
|
||||
// Go generated these from `SecretKeyFromSeed([n; 32])`. Regenerating the
|
||||
// key from the same seed here and getting the same public key is itself
|
||||
// a conformance check — Go and this crate derive one key from one seed —
|
||||
// and it yields the secret needed to form the proof of possession that
|
||||
// registration now requires. If the two KDFs ever diverged, this
|
||||
// assertion would catch it before any signature test ran.
|
||||
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 pop = sk.sign(&pop_message(&id, &pk_bytes), POP_DST, &[]).compress().to_vec();
|
||||
|
||||
set.insert(id, 100, &pk_bytes, &pop)
|
||||
.expect("Go public key rejected at registration");
|
||||
let signature = hex::decode(sig).unwrap();
|
||||
assert!(
|
||||
|
||||
+121
-24
@@ -32,7 +32,9 @@ use blst::{
|
||||
blst_p1_from_affine, blst_p1_to_affine, blst_p1_uncompress, BLST_ERROR,
|
||||
};
|
||||
|
||||
use lux_consensus::cert::{CertError, QuorumCert, ValidatorSet, Vote, VoteVerifier, DST};
|
||||
use lux_consensus::cert::{
|
||||
pop_message, CertError, QuorumCert, ValidatorSet, Vote, VoteVerifier, DST, POP_DST,
|
||||
};
|
||||
use lux_consensus::finality::{canonical_vote_message, Finality, Id, Position};
|
||||
use lux_consensus::quasar::QuasarConsensus;
|
||||
use lux_consensus::{NodeID, QuasarConfig};
|
||||
@@ -91,6 +93,15 @@ impl Honest {
|
||||
fn pk(&self) -> [u8; 48] {
|
||||
self.sk.sk_to_pk().compress()
|
||||
}
|
||||
|
||||
/// A genuine proof of possession — this validator holds the secret, so it
|
||||
/// can sign its own (node, key) under the proof-of-possession DST.
|
||||
fn pop(&self) -> Vec<u8> {
|
||||
self.sk
|
||||
.sign(&pop_message(&self.id, &self.pk()), POP_DST, &[])
|
||||
.compress()
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// The attacker: a registered key it cannot sign under, and a secret whose real
|
||||
@@ -101,6 +112,19 @@ struct Rogue {
|
||||
sk: SecretKey,
|
||||
}
|
||||
|
||||
impl Rogue {
|
||||
/// The best proof of possession the attacker can produce for the key it
|
||||
/// registered: a signature by the only secret it holds. That secret's public
|
||||
/// key is the SUM (`target`), not the cancelling key `pk`, so this proof does
|
||||
/// not verify against `pk` — which is exactly why possession cannot be faked.
|
||||
fn forged_pop(&self) -> Vec<u8> {
|
||||
self.sk
|
||||
.sign(&pop_message(&self.id, &self.pk), POP_DST, &[])
|
||||
.compress()
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the rogue key that cancels `others` out of a sum.
|
||||
fn forge_key(others: &[[u8; 48]], id_byte: u8) -> Rogue {
|
||||
let sk = SecretKey::key_gen(&[0xA0 | id_byte; 32], &[]).expect("key_gen");
|
||||
@@ -135,12 +159,19 @@ fn stage() -> (Vec<Honest>, Rogue, ValidatorSet) {
|
||||
|
||||
let mut set = ValidatorSet::new();
|
||||
for h in &honest {
|
||||
set.insert(h.id, 100, &h.pk()).expect("honest registers");
|
||||
set.insert(h.id, 100, &h.pk(), &h.pop())
|
||||
.expect("honest registers");
|
||||
}
|
||||
// The registration the attack needs, and it succeeds: `key_validate` has no
|
||||
// opinion about who holds the secret.
|
||||
set.insert(rogue.id, 100, &rogue.pk)
|
||||
.expect("a rogue key registers");
|
||||
// The registration the attack needs no longer succeeds: proof of possession
|
||||
// has an opinion about who holds the secret, and the rogue holds none for the
|
||||
// key it published. The set the attack must work against therefore contains
|
||||
// the honest three and not the rogue — see
|
||||
// `the_rogue_key_cannot_register_without_possession`.
|
||||
assert_eq!(
|
||||
set.insert(rogue.id, 100, &rogue.pk, &rogue.forged_pop()),
|
||||
Err(CertError::PopInvalid),
|
||||
"the rogue cannot prove possession of a key it does not control"
|
||||
);
|
||||
|
||||
(honest, rogue, set)
|
||||
}
|
||||
@@ -224,19 +255,63 @@ fn the_forged_signature_is_refused_vote_by_vote() {
|
||||
assert_eq!(cert.verify(&set, 0), Err(CertError::SigInvalid(0)));
|
||||
}
|
||||
|
||||
/// The rogue registrant cannot even cast its own vote. It published
|
||||
/// `g1·x − Σ pk_others` and does not know that key's secret, so there is no
|
||||
/// signature it can produce that verifies under it. Registering a rogue key
|
||||
/// costs the attacker its own ballot.
|
||||
/// THE POP DEFENSE. The rogue published `g1·x − Σ pk_others` and does not hold
|
||||
/// that key's secret, so it cannot produce a proof of possession for it. Every
|
||||
/// proof it can offer is refused, so the key never enters the set — the forgery
|
||||
/// is stopped one step earlier than the per-signature check, at registration.
|
||||
#[test]
|
||||
fn the_rogue_registrant_cannot_sign_for_itself() {
|
||||
let (_, rogue, set) = stage();
|
||||
let message = canonical_vote_message(&position(), true);
|
||||
fn the_rogue_key_cannot_register_without_possession() {
|
||||
let honest: Vec<Honest> = (1..=3u8).map(Honest::new).collect();
|
||||
let pks: Vec<[u8; 48]> = honest.iter().map(|h| h.pk()).collect();
|
||||
let rogue = forge_key(&pks, 4);
|
||||
|
||||
let mut set = ValidatorSet::new();
|
||||
for h in &honest {
|
||||
set.insert(h.id, 100, &h.pk(), &h.pop()).expect("honest registers");
|
||||
}
|
||||
|
||||
// The best proof the attacker can make — by the one secret it holds, whose
|
||||
// public key is the sum, not the registered cancelling key.
|
||||
assert_eq!(
|
||||
set.insert(rogue.id, 100, &rogue.pk, &rogue.forged_pop()),
|
||||
Err(CertError::PopInvalid),
|
||||
);
|
||||
// No proof at all, and a well-formed proof over the wrong (someone else's)
|
||||
// key, are refused just the same.
|
||||
assert_eq!(set.insert(rogue.id, 100, &rogue.pk, &[]), Err(CertError::PopInvalid));
|
||||
assert_eq!(
|
||||
set.insert(rogue.id, 100, &rogue.pk, &honest[0].pop()),
|
||||
Err(CertError::PopInvalid),
|
||||
);
|
||||
|
||||
// The rogue is not a member, so nothing it signs is even looked up.
|
||||
let message = canonical_vote_message(&position(), true);
|
||||
let own = rogue.sk.sign(&message, DST, &[]).compress().to_vec();
|
||||
assert!(
|
||||
!set.verify_vote(&rogue.id, &message, &own, 0),
|
||||
"the attacker has no secret for the key it registered"
|
||||
assert!(!set.verify_vote(&rogue.id, &message, &own, 0));
|
||||
}
|
||||
|
||||
/// A key belongs to one validator: it cannot be registered under a second id,
|
||||
/// so `nova_signer_floor` counts distinct signers and not ids one holder mints.
|
||||
/// This is the other half of the floor's guarantee — proof of possession stops
|
||||
/// a key you do not hold; uniqueness stops a key you do hold from voting twice.
|
||||
#[test]
|
||||
fn one_secret_key_cannot_be_registered_under_two_ids() {
|
||||
let h = Honest::new(1);
|
||||
let mut set = ValidatorSet::new();
|
||||
set.insert(h.id, 100, &h.pk(), &h.pop()).expect("first id");
|
||||
|
||||
// The same key, a second id, and a valid proof of possession for that id —
|
||||
// still refused, because the key already belongs to the first.
|
||||
let mut second = [0u8; 32];
|
||||
second[0] = 9;
|
||||
let pop_second = h
|
||||
.sk
|
||||
.sign(&pop_message(&second, &h.pk()), POP_DST, &[])
|
||||
.compress()
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
set.insert(second, 100, &h.pk(), &pop_second),
|
||||
Err(CertError::DuplicateKey),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,12 +330,14 @@ fn the_engine_issues_nothing_from_a_forged_signature() {
|
||||
let mut quasar = QuasarConsensus::new(&config);
|
||||
for h in &honest {
|
||||
quasar
|
||||
.add_validator_with_key(NodeID::from(h.id), 100, &h.pk())
|
||||
.add_validator_with_key(NodeID::from(h.id), 100, &h.pk(), &h.pop())
|
||||
.expect("register");
|
||||
}
|
||||
quasar
|
||||
.add_validator_with_key(NodeID::from(rogue.id), 100, &rogue.pk)
|
||||
.expect("a rogue key registers here too");
|
||||
// The rogue is refused here too — the engine registration path verifies the
|
||||
// same proof of possession.
|
||||
assert!(quasar
|
||||
.add_validator_with_key(NodeID::from(rogue.id), 100, &rogue.pk, &rogue.forged_pop())
|
||||
.is_err());
|
||||
|
||||
let pos = position();
|
||||
let message = canonical_vote_message(&pos, true);
|
||||
@@ -305,7 +382,7 @@ fn a_certificate_names_only_what_was_signed() {
|
||||
let mut quasar = QuasarConsensus::new(&config);
|
||||
for h in &honest {
|
||||
quasar
|
||||
.add_validator_with_key(NodeID::from(h.id), 100, &h.pk())
|
||||
.add_validator_with_key(NodeID::from(h.id), 100, &h.pk(), &h.pop())
|
||||
.expect("register");
|
||||
}
|
||||
|
||||
@@ -329,13 +406,33 @@ fn a_certificate_names_only_what_was_signed() {
|
||||
assert!(quasar.verify_certificate(&cert));
|
||||
assert_eq!(cert.position, pos, "the position is the whole claim");
|
||||
|
||||
// Re-label it: the certificate now names a different block, and every
|
||||
// signature it carries stops verifying.
|
||||
// Re-label a SIGNED axis: the certificate now names a different block, and
|
||||
// every signature it carries stops verifying.
|
||||
let mut relabelled = cert.clone();
|
||||
relabelled.position.canonical_id = [0xEE; 32];
|
||||
assert!(!quasar.verify_certificate(&relabelled));
|
||||
|
||||
let mut higher = cert;
|
||||
let mut higher = cert.clone();
|
||||
higher.position.height = 999_999;
|
||||
assert!(!quasar.verify_certificate(&higher));
|
||||
|
||||
// Re-label the UNSIGNED transport id: verification still passes, because the
|
||||
// transport id is deliberately outside the signature — so finality must NOT
|
||||
// be keyed on it. It is keyed on the signed identity, so the relabelled
|
||||
// transport id finalizes nothing of its own, and the real signed identity is
|
||||
// the one that is finalized.
|
||||
let mut transport = cert;
|
||||
transport.position.block_id = [0xAB; 32];
|
||||
assert!(
|
||||
quasar.verify_certificate(&transport),
|
||||
"the transport id is unsigned, so mutating it cannot break verification"
|
||||
);
|
||||
assert!(
|
||||
!quasar.is_finalized(&lux_consensus::ID::from([0xAB; 32])),
|
||||
"a relabelled transport id finalizes nothing"
|
||||
);
|
||||
assert!(
|
||||
quasar.is_finalized(&lux_consensus::ID::from(pos.signed_identity())),
|
||||
"finality is keyed on the signed identity"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user