diff --git a/node/src/aggregates/graph_jobs.rs b/node/src/aggregates/graph_jobs.rs index 46187387..5f701079 100644 --- a/node/src/aggregates/graph_jobs.rs +++ b/node/src/aggregates/graph_jobs.rs @@ -41,7 +41,8 @@ use crate::error::{FailureClass, SpecialError, classify_failure}; use crate::messages::{ AggNonceConsensus, CommitteePresign, CreateGraph, CutCircuits, EndorseGraph, GOATMessage, GOATMessageContent, GenCircuits, GraphFinalize, NonceGeneration, - VerifierGraphParamsEndorsement, verify_init_graph_signature, + VerifierGraphParamsEndorsement, sign_gen_circuits, verify_gen_circuits_signature, + verify_init_graph_signature, }; use crate::soldering_payload_store::{ read_soldering_proof_store_payload, soldering_proof_payload_store_path, @@ -324,15 +325,27 @@ async fn operator_init_graph(_deps: &JobDeps, graph_id: Uuid, params: &[u8]) -> async fn ingest_gen_circuits(deps: &JobDeps, graph_id: Uuid, params: &[u8]) -> Result { let (inbound, message) = decode_inbound(params).await?; - let GOATMessageContent::GenCircuits(GenCircuits { + let GOATMessageContent::GenCircuits(gen_circuits) = message.content else { + return invalid("not a GenCircuits message"); + }; + let verifier_peer = author_peer(&inbound)?; + let verifier_peer_id = verifier_peer.to_bytes(); + // Without the key holder's signature for this peer, a registered verifier + // could claim another verifier's key first, and the real holder would be + // refused as "bound to another peer". + if !verify_gen_circuits_signature(&gen_circuits, &verifier_peer_id) { + return Step::ignore(format!( + "GenCircuits from {verifier_peer} is not signed by verifier key {} for that peer", + gen_circuits.verifier_pubkey + )); + } + let GenCircuits { instance_id, graph_id: message_graph_id, verifier_pubkey, setup_package, - }) = message.content - else { - return invalid("not a GenCircuits message"); - }; + signature: _, + } = gen_circuits; if message_graph_id != graph_id || instance_id != inbound.instance_id { return invalid("GenCircuits identity mismatch"); } @@ -353,8 +366,6 @@ async fn ingest_gen_circuits(deps: &JobDeps, graph_id: Uuid, params: &[u8]) -> R setup_package.commits.len() )); } - let verifier_peer = author_peer(&inbound)?; - let verifier_peer_id = verifier_peer.to_bytes(); if !crate::p2p_admission::peer_registry().is_verifier(&verifier_peer) && !deps.goat_client.committee_mana_is_verifier(&verifier_peer_id).await.with_context( || format!("validate GenCircuits sender {verifier_peer} against the verifier registry"), @@ -1315,7 +1326,16 @@ async fn verifier_setup(deps: &JobDeps, graph_id: Uuid, params: &[u8]) -> Result soldering_proof_ready: None, } }; - let setup_package = verifier_state.setup_package.clone(); + let local_peer_id = PeerId::from_str(&get_peer_id()) + .context("decode local verifier peer id for GenCircuits")? + .to_bytes(); + let gen_circuits = sign_gen_circuits( + instance_id, + graph_id, + &verifier_master_key.master_keypair(), + &local_peer_id, + verifier_state.setup_package.clone(), + ); update_babe_setup_state(&deps.local_db, instance_id, graph_id, |state| { state.verifier = Some(verifier_state); })?; @@ -1326,12 +1346,7 @@ async fn verifier_setup(deps: &JobDeps, graph_id: Uuid, params: &[u8]) -> Result graph_id, PHASE_SETUP, Actor::Operator, - GOATMessageContent::GenCircuits(GenCircuits { - instance_id, - graph_id, - verifier_pubkey, - setup_package, - }), + GOATMessageContent::GenCircuits(gen_circuits), std::slice::from_ref(&operator_peer), None, ) diff --git a/node/src/messages.rs b/node/src/messages.rs index 2aeeeabb..6d8e4425 100644 --- a/node/src/messages.rs +++ b/node/src/messages.rs @@ -206,6 +206,62 @@ pub struct GenCircuits { pub graph_id: Uuid, pub verifier_pubkey: PublicKey, pub setup_package: CACSetupPackage, + /// `verifier_pubkey`'s signature binding it to the sending peer for this + /// graph; see [`sign_gen_circuits`]. + pub signature: SchnorrSignature, +} + +const GEN_CIRCUITS_SIGNATURE_DOMAIN: &[u8] = b"bitvm2-node/gen-circuits/v1"; + +/// The setup package is not signed: gossip already authenticates the sending +/// peer. What needs the key holder's consent is the claim that this peer +/// speaks for `verifier_pubkey` in this graph; without it a registered +/// verifier could claim another verifier's key first. +fn gen_circuits_signature_message( + instance_id: Uuid, + graph_id: Uuid, + verifier_pubkey: &PublicKey, + verifier_peer_id: &[u8], +) -> SecpMessage { + let mut hasher = Sha256::new(); + hasher.update(GEN_CIRCUITS_SIGNATURE_DOMAIN); + hasher.update(instance_id.as_bytes()); + hasher.update(graph_id.as_bytes()); + hasher.update(verifier_pubkey.to_bytes()); + hasher.update((verifier_peer_id.len() as u32).to_be_bytes()); + hasher.update(verifier_peer_id); + SecpMessage::from_digest(hasher.finalize().into()) +} + +pub fn sign_gen_circuits( + instance_id: Uuid, + graph_id: Uuid, + verifier_keypair: &Keypair, + verifier_peer_id: &[u8], + setup_package: CACSetupPackage, +) -> GenCircuits { + let verifier_pubkey = verifier_keypair.public_key().into(); + let signature = SECP256K1.sign_schnorr( + &gen_circuits_signature_message(instance_id, graph_id, &verifier_pubkey, verifier_peer_id), + verifier_keypair, + ); + GenCircuits { instance_id, graph_id, verifier_pubkey, setup_package, signature } +} + +/// Whether `message.verifier_pubkey` signed its binding to `sender`. +pub fn verify_gen_circuits_signature(message: &GenCircuits, sender: &[u8]) -> bool { + SECP256K1 + .verify_schnorr( + &message.signature, + &gen_circuits_signature_message( + message.instance_id, + message.graph_id, + &message.verifier_pubkey, + sender, + ), + &XOnlyPublicKey::from(message.verifier_pubkey), + ) + .is_ok() } #[derive(Serialize, Deserialize, Clone)] pub struct CutCircuits { @@ -449,6 +505,28 @@ mod tests { assert!(!object.contains_key("verifier_pubkey")); } + #[test] + fn gen_circuits_binds_the_verifier_key_to_its_sender() { + let keypair = |seed| Keypair::from_seckey_slice(SECP256K1, &[seed; 32]).unwrap(); + let (instance_id, graph_id) = (Uuid::new_v4(), Uuid::new_v4()); + let (holder, other) = (libp2p::PeerId::random(), libp2p::PeerId::random()); + let package = || bitvm_lib::babe_adapter::build_setup_package(1).unwrap(); + let message = + sign_gen_circuits(instance_id, graph_id, &keypair(1), &holder.to_bytes(), package()); + assert!(verify_gen_circuits_signature(&message, &holder.to_bytes())); + // The holder's message relayed or replayed by another peer. + assert!(!verify_gen_circuits_signature(&message, &other.to_bytes())); + // Another peer claiming the holder's key with its own signature. + let mut squatted = + sign_gen_circuits(instance_id, graph_id, &keypair(2), &other.to_bytes(), package()); + squatted.verifier_pubkey = message.verifier_pubkey; + assert!(!verify_gen_circuits_signature(&squatted, &other.to_bytes())); + // The holder's signature reused for another graph. + let mut moved = message.clone(); + moved.graph_id = Uuid::new_v4(); + assert!(!verify_gen_circuits_signature(&moved, &holder.to_bytes())); + } + #[test] fn every_wire_kind_is_routed_or_immediate() { use strum::IntoEnumIterator; diff --git a/node/src/p2p_admission/tests.rs b/node/src/p2p_admission/tests.rs index 15972b78..318cfd03 100644 --- a/node/src/p2p_admission/tests.rs +++ b/node/src/p2p_admission/tests.rs @@ -146,19 +146,19 @@ fn envelope_limits_depend_on_the_encoding_and_the_sender() { /// Verify GenCircuits fits the binary transport ceiling. #[tokio::test] async fn gen_circuits_fits_the_binary_envelope() { - use crate::messages::GenCircuits; use bitvm_lib::babe_adapter::{BABE_N_CC, build_setup_package}; let secp = bitcoin::secp256k1::Secp256k1::new(); let secret = bitcoin::secp256k1::SecretKey::from_slice(&[7; 32]).unwrap(); let message = GOATMessage::new( Actor::Operator, - GOATMessageContent::GenCircuits(GenCircuits { - instance_id: Uuid::new_v4(), - graph_id: Uuid::new_v4(), - verifier_pubkey: bitcoin::PublicKey::new(secret.public_key(&secp)), - setup_package: build_setup_package(BABE_N_CC).unwrap(), - }), + GOATMessageContent::GenCircuits(crate::messages::sign_gen_circuits( + Uuid::new_v4(), + Uuid::new_v4(), + &bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret), + &libp2p::PeerId::random().to_bytes(), + build_setup_package(BABE_N_CC).unwrap(), + )), ); let bytes = message.serialize_message().await.unwrap(); assert!(GOATMessage::is_binary_envelope(&bytes));