mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
rust: the node-bound proof of possession, conforming to the Go oracle
pkg/rust gains pop.rs — the 20-byte-NodeID-bound proof of possession, the same bytes the Go validator/pop froze in the conformance corpus. tests/pop_conformance reads that frozen vector and holds this crate to it: pop::verify returns the Go verifier's accept/reject, and on a reject its error class, for every case, including the encoding-leg rejects (identity key and proof, non-subgroup point) that a lax decoder would wrongly pass. This is the primitive; wiring it into ValidatorSet registration — one key to one node, the 20-byte identity threaded through the vote plane — is the eligibility step that follows. The forgery fix already on main (per-signature verification, no aggregate) stands; this adds the possession proof the registration side was missing. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -66,6 +66,7 @@ pub mod finality;
|
||||
// The quorum certificate: the predicate that decides whether a block is
|
||||
// accepted. Held to `engine/chain/cert.go` by tests/cert.rs.
|
||||
pub mod cert;
|
||||
pub mod pop;
|
||||
|
||||
/// SHA-256. The crate's one hash, from the BLS library already linked here.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//! Node-bound proof of possession — the Rust side of the standard the Go oracle
|
||||
//! froze in `luxfi/conformance/vectors/pop.json`.
|
||||
//!
|
||||
//! A registrant proves it holds the key it registers AND that the key names the
|
||||
//! node it registers it for. The pubkey-only IETF proof binds nothing but the
|
||||
//! key, so it travels: an honest validator's published key and proof re-register
|
||||
//! under a second identity. Binding the node closes that — a proof is valid only
|
||||
//! for the one (node, key) pair it was made for.
|
||||
//!
|
||||
//! THE MESSAGE, byte for byte, identical in Go, Rust and C++:
|
||||
//!
|
||||
//! ```text
|
||||
//! offset 0 .. 19 node — the 20-byte NodeID
|
||||
//! offset 20 .. 67 key — compressed G1 pubkey, 48 bytes
|
||||
//! total 68 bytes
|
||||
//! ```
|
||||
//!
|
||||
//! No separator, no length prefix — both fields are fixed width. The node is the
|
||||
//! raw 20-byte identity a Lux validator carries, never the 32-byte block id.
|
||||
//!
|
||||
//! THE CIPHERSUITE is BLS12-381 `min_pk` under the proof-of-possession domain
|
||||
//! `BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_` — the `_POP_` tag, never the
|
||||
//! vote's `..._NUL_`, so a vote is not a proof and a proof is not a vote. Verify
|
||||
//! order: encoding, then possession.
|
||||
|
||||
use blst::min_pk::{PublicKey, Signature};
|
||||
use blst::BLST_ERROR;
|
||||
|
||||
/// The 20-byte validator identity — the same value Go's `ids.NodeID` and C++'s
|
||||
/// NodeID carry. Distinct from the 32-byte block [`crate::finality::Id`].
|
||||
pub type NodeId = [u8; 20];
|
||||
|
||||
/// The proof-of-possession domain. Distinct from the vote domain by `_POP_`.
|
||||
pub const POP_DST: &[u8] = b"BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_";
|
||||
|
||||
/// Width of the node identity in the message.
|
||||
pub const NODE_LEN: usize = 20;
|
||||
/// Width of a compressed BLS12-381 min_pk public key (G1).
|
||||
pub const KEY_LEN: usize = 48;
|
||||
/// Width of a compressed BLS12-381 min_pk signature (G2) — the proof.
|
||||
pub const PROOF_LEN: usize = 96;
|
||||
/// The whole preimage: node ‖ key.
|
||||
pub const MESSAGE_LEN: usize = NODE_LEN + KEY_LEN;
|
||||
|
||||
/// Why a proof was refused — the same three classes the Go oracle names, so a
|
||||
/// conforming implementation rejects for the same reason, not merely rejects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PopError {
|
||||
/// The public key is not a canonical compressed BLS12-381 G1 point: wrong
|
||||
/// width, non-canonical encoding, off-curve, outside the prime-order
|
||||
/// subgroup, or the identity.
|
||||
Key,
|
||||
/// The proof is not a canonical compressed BLS12-381 G2 point, by the same
|
||||
/// measures.
|
||||
Proof,
|
||||
/// The bytes decode but the proof does not bind this node to this key.
|
||||
Possession,
|
||||
}
|
||||
|
||||
/// The exact bytes a node-bound proof signs: node ‖ key, 68 bytes.
|
||||
pub fn message(node: &NodeId, key: &[u8]) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(MESSAGE_LEN);
|
||||
m.extend_from_slice(node);
|
||||
m.extend_from_slice(key);
|
||||
m
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if key.len() != KEY_LEN {
|
||||
return Err(PopError::Key);
|
||||
}
|
||||
let pk = PublicKey::key_validate(key).map_err(|_| PopError::Key)?;
|
||||
// One point, one encoding. blst's decode already refuses a non-canonical
|
||||
// spelling (x >= p) and a non-subgroup point; re-checking that the point
|
||||
// re-compresses to the exact input bytes is the same guard the Go oracle
|
||||
// keeps, so a decoder that were ever laxer here is caught.
|
||||
if pk.compress().as_slice() != key {
|
||||
return Err(PopError::Key);
|
||||
}
|
||||
if proof.len() != PROOF_LEN {
|
||||
return Err(PopError::Proof);
|
||||
}
|
||||
let sig = Signature::uncompress(proof).map_err(|_| PopError::Proof)?;
|
||||
// Decode is not enough: `uncompress` accepts the identity and off-subgroup
|
||||
// points, where Go's `SignatureFromBytes` refuses them. Validate the point
|
||||
// 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 {
|
||||
return Err(PopError::Possession);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//! Rust conforms to the Go oracle, byte for byte.
|
||||
//!
|
||||
//! `tests/vectors/pop.json` is the frozen corpus from `luxfi/conformance`,
|
||||
//! generated by the Go `validator/pop` and self-checked against its verifier.
|
||||
//! Every case carries the exact node, key and proof bytes and the accept/reject
|
||||
//! the Go verifier returned. This asserts the Rust `pop::verify` returns the
|
||||
//! same answer AND, on a reject, the same error class — so a certificate that
|
||||
//! verifies in Go verifies here, and one Go refuses is refused here for the same
|
||||
//! reason.
|
||||
|
||||
use lux_consensus::pop::{self, NodeId, PopError};
|
||||
|
||||
// A tiny JSON reach into the vector, without a serde dependency: the file is a
|
||||
// flat, machine-generated object, one case per element.
|
||||
fn field<'a>(obj: &'a str, key: &str) -> &'a str {
|
||||
let k = format!("\"{key}\": \"");
|
||||
let p = obj.find(&k).map(|i| i + k.len()).unwrap_or_else(|| {
|
||||
panic!("case has no {key}: {obj}");
|
||||
});
|
||||
&obj[p..p + obj[p..].find('"').unwrap()]
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// The three error classes, spelled exactly as the Go oracle publishes them, so a
|
||||
// mismatch names which clause diverged.
|
||||
fn class(e: Option<PopError>) -> &'static str {
|
||||
match e {
|
||||
None => "",
|
||||
Some(PopError::Key) => "pop: public key is not a canonical compressed BLS12-381 G1 point",
|
||||
Some(PopError::Proof) => "pop: proof is not a canonical compressed BLS12-381 G2 point",
|
||||
Some(PopError::Possession) => "pop: proof does not bind this node to this key",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_matches_the_go_pop_oracle_case_for_case() {
|
||||
let raw = include_str!("vectors/pop.json");
|
||||
|
||||
// Split into case objects on the boundary between array elements. The
|
||||
// generator writes one object per case; each contains a "name".
|
||||
let cases: Vec<&str> = raw.split("\"name\":").skip(1).collect();
|
||||
assert!(cases.len() >= 8, "vector shrank to {} cases", cases.len());
|
||||
|
||||
let mut checked = 0;
|
||||
for chunk in cases {
|
||||
let name = {
|
||||
let s = chunk.trim_start();
|
||||
let s = &s[s.find('"').unwrap() + 1..];
|
||||
&s[..s.find('"').unwrap()]
|
||||
};
|
||||
let node_hex = field(chunk, "node");
|
||||
let key = unhex(field(chunk, "key"));
|
||||
let proof = unhex(field(chunk, "proof"));
|
||||
let accepted = chunk.contains("\"accepted\": true");
|
||||
|
||||
let node_bytes = unhex(node_hex);
|
||||
assert_eq!(node_bytes.len(), 20, "{name}: node is not 20 bytes");
|
||||
let mut node: NodeId = [0u8; 20];
|
||||
node.copy_from_slice(&node_bytes);
|
||||
|
||||
let got = pop::verify(&node, &key, &proof);
|
||||
assert_eq!(
|
||||
got.is_ok(),
|
||||
accepted,
|
||||
"{name}: Rust says accepted={}, Go said accepted={accepted}",
|
||||
got.is_ok(),
|
||||
);
|
||||
if !accepted {
|
||||
let want = field(chunk, "error");
|
||||
assert_eq!(class(got.err()), want, "{name}: error class diverged");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 13, "expected 13 frozen cases, checked {checked}");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"note": "Message = node(20) || compressed-G1-pubkey(48), 68 bytes, no separator, signed under DST as a BLS12-381 min_pk proof of possession (compressed G2, 96 bytes). Verify order: encoding, then possession. A conforming implementation must accept exactly the valid case and refuse every other with the named error class.",
|
||||
"source": "github.com/luxfi/consensus/validator/pop",
|
||||
"dst": "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_",
|
||||
"node_len": 20,
|
||||
"key_len": 48,
|
||||
"proof_len": 96,
|
||||
"message_len": 68,
|
||||
"layout": "node[0:20] || key[20:68]",
|
||||
"cases": [
|
||||
{
|
||||
"name": "valid: the proof binds its own node and key",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": true
|
||||
},
|
||||
{
|
||||
"name": "lifted: node 1's proof under node 2's identity",
|
||||
"node": "2222222222222222222222222222222222222222",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": false,
|
||||
"error": "pop: proof does not bind this node to this key"
|
||||
},
|
||||
{
|
||||
"name": "wrong key: node 1's proof over node 2's key",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "ac80a5e08c712d5f08f0306ad743f7d8c215d982489b84a1d6ba805733d94c006e8938f9089a75db3ffa135af33bc69a",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": false,
|
||||
"error": "pop: proof does not bind this node to this key"
|
||||
},
|
||||
{
|
||||
"name": "another pair's proof: node 2's proof for node 1",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "940f886a1704ce5776ff2aaaed2763f774b5aec4c30e1216191de63038502115da66cf5d85b83c40d48d389e6bfa32350f5e98c5d3c895c0788404126b72c83e735ecd74fe893381478dd7c68fde7ffcdea19981965be48dcf815e9a9302d6fd",
|
||||
"accepted": false,
|
||||
"error": "pop: proof does not bind this node to this key"
|
||||
},
|
||||
{
|
||||
"name": "vote domain: the message signed under ..._NUL_ not ..._POP_",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "a90b843da2a53fd31630d39032e37b869aa8113b00e89d35e938e74636a56a5f311ea37ba41fd0d1f91ec4431cb0038600781df69eb55ae586afd2924475edb5c69f965140806192db67c1a14f208dbd9b7ca9df1e08dbc7d931ddc9117b2093",
|
||||
"accepted": false,
|
||||
"error": "pop: proof does not bind this node to this key"
|
||||
},
|
||||
{
|
||||
"name": "empty proof",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "",
|
||||
"accepted": false,
|
||||
"error": "pop: proof is not a canonical compressed BLS12-381 G2 point"
|
||||
},
|
||||
{
|
||||
"name": "garbage proof: 96 bytes of 0xAB",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
||||
"accepted": false,
|
||||
"error": "pop: proof is not a canonical compressed BLS12-381 G2 point"
|
||||
},
|
||||
{
|
||||
"name": "short proof: 95 bytes",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
||||
"accepted": false,
|
||||
"error": "pop: proof is not a canonical compressed BLS12-381 G2 point"
|
||||
},
|
||||
{
|
||||
"name": "long proof: 97 bytes",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
||||
"accepted": false,
|
||||
"error": "pop: proof is not a canonical compressed BLS12-381 G2 point"
|
||||
},
|
||||
{
|
||||
"name": "identity proof: compressed G2 infinity",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c253b",
|
||||
"proof": "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"accepted": false,
|
||||
"error": "pop: proof is not a canonical compressed BLS12-381 G2 point"
|
||||
},
|
||||
{
|
||||
"name": "identity key: compressed G1 infinity",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": false,
|
||||
"error": "pop: public key is not a canonical compressed BLS12-381 G1 point"
|
||||
},
|
||||
{
|
||||
"name": "short key: 47 bytes",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "95a254501b7733239ed3cec4d56737977bd09ede881d8a234560e83e5525017add3b1dcc3eabfb85e12a4131b19c25",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": false,
|
||||
"error": "pop: public key is not a canonical compressed BLS12-381 G1 point"
|
||||
},
|
||||
{
|
||||
"name": "non-canonical key: on-curve point outside the prime-order subgroup",
|
||||
"node": "1111111111111111111111111111111111111111",
|
||||
"key": "800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004",
|
||||
"proof": "b93ce2cb351e91a3e651619a9559ec582ae036a1d450e9e567c9810a9da755a5d2e58ae97ff71a7b3a3f59b354151b8c14ec3d9f915691326100c63d3c74d7669d30b6a6386ec6f538973ef362d1923c954cdc066c8b44072ae22bb3bfd690da",
|
||||
"accepted": false,
|
||||
"error": "pop: public key is not a canonical compressed BLS12-381 G1 point"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user