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:
2026-09-01 08:14:10 -07:00
co-authored by Hanzo Dev
parent b660d19f51
commit 8be65b8dd9
4 changed files with 308 additions and 0 deletions
+1
View File
@@ -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.
///
+108
View File
@@ -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()
}
+84
View File
@@ -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}");
}
+115
View File
@@ -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"
}
]
}