From c085df196759a846d49982f1155ea484c3fcb3ba Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:30:50 +0800 Subject: [PATCH 1/8] update kdf --- Cargo.lock | 2 + Cargo.toml | 2 + crates/bitvm2-ga/Cargo.toml | 2 + crates/bitvm2-ga/src/committee/api.rs | 37 +-- crates/bitvm2-ga/src/keys.rs | 454 ++++++++++++++++++++++++-- crates/bitvm2-ga/src/operator/api.rs | 34 +- node/src/env.rs | 1 + node/src/handle.rs | 132 +++++--- 8 files changed, 554 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0704ab4a..5c4ea744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2584,11 +2584,13 @@ dependencies = [ "bitcoin-script", "bitcoincore-rpc", "bitvm", + "chacha20poly1305", "clap", "client", "esplora-client", "goat", "hex", + "hkdf", "musig2", "rand 0.8.5", "secp256k1 0.29.1", diff --git a/Cargo.toml b/Cargo.toml index 7d6a2fe7..df96280d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ ark-groth16 = "0.5.0" base64 = "0.21" ark-serialize = "0.5.0" sha2 = "0.10.9" +hkdf = "0.12.4" +chacha20poly1305 = "0.10.1" #tokio = { version = "1.37.0", features = ["full"] } tokio-util = "0.7.15" esplora-client = { git = "https://github.com/BitVM/rust-esplora-client" } diff --git a/crates/bitvm2-ga/Cargo.toml b/crates/bitvm2-ga/Cargo.toml index cfbbd21f..4210348b 100644 --- a/crates/bitvm2-ga/Cargo.toml +++ b/crates/bitvm2-ga/Cargo.toml @@ -11,6 +11,8 @@ ark-groth16 = { workspace = true } ark-bn254 = { workspace = true } ark-serialize = { workspace = true } sha2 = { workspace = true } +hkdf = { workspace = true } +chacha20poly1305 = { workspace = true } rand = { workspace = true } bitcoin = { workspace = true, features = ["serde"] } musig2 = { workspace = true } diff --git a/crates/bitvm2-ga/src/committee/api.rs b/crates/bitvm2-ga/src/committee/api.rs index 228600e4..e92eb8cc 100644 --- a/crates/bitvm2-ga/src/committee/api.rs +++ b/crates/bitvm2-ga/src/committee/api.rs @@ -1,3 +1,4 @@ +use crate::keys::hkdf_derive_bytes; use crate::types::Bitvm2Graph; use anyhow::{Result, bail}; use bitcoin::{PublicKey, Transaction, XOnlyPublicKey}; @@ -15,12 +16,11 @@ use goat::connectors::watchtower_connectors::{ use goat::contexts::base::generate_n_of_n_public_key; use goat::transactions::pre_signed_musig2::{get_nonce_message, verify_public_nonce}; use goat::transactions::{base::BaseTransaction, signing_musig2::generate_aggregated_nonce}; -use hex::FromHex; -use hex::ToHex; use musig2::{AggNonce, PartialSignature, PubNonce, SecNonce}; use secp256k1::schnorr::Signature as SchnorrSignature; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; + +const COMMITTEE_NONCE_HKDF_SALT: &[u8] = b"bitvm2/committee-nonce/v1"; pub fn key_aggregation(pubkeys: &[PublicKey]) -> PublicKey { generate_n_of_n_public_key(pubkeys).0 @@ -535,11 +535,6 @@ pub fn push_committee_pre_signatures( Ok(()) } -pub fn generate_keypair_from_seed(seed: String) -> Keypair { - let keypair_secret = sha256(&format!("{seed}/master")); - Keypair::from_seckey_str_global(&keypair_secret).unwrap() -} - pub fn generate_nonce_from_seed( seed: String, graph_index: usize, @@ -547,7 +542,12 @@ pub fn generate_nonce_from_seed( watchtower_num: usize, assert_commit_num: usize, ) -> (CommitteePubNonces, CommitteeSecNonces, CommitteeNonceSignatures) { - let graph_seed = sha256_with_id(&seed, graph_index); + let graph_seed = hkdf_derive_bytes( + seed.as_bytes(), + COMMITTEE_NONCE_HKDF_SALT, + format!("graph/{graph_index}").as_bytes(), + 32, + ); let mut pub_nonces = CommitteePubNonces::new_empty(); let mut sec_nonces = CommitteeSecNonces::new_empty(); let mut nonce_sigs = CommitteeNonceSignatures::new_empty(); @@ -668,24 +668,15 @@ pub fn verify_nonce_signatures( pub(crate) fn generate_nonce( signer_keypair: Keypair, - seed: &str, + seed: &[u8], index: usize, ) -> (SecNonce, PubNonce, SchnorrSignature) { - let nonce_seed = sha256_with_id(seed, index); - let nonce_seed = <[u8; 32]>::from_hex(&nonce_seed).unwrap(); + let nonce_seed = + hkdf_derive_bytes(seed, COMMITTEE_NONCE_HKDF_SALT, format!("nonce/{index}").as_bytes(), 32); + let nonce_seed: [u8; 32] = + nonce_seed.try_into().expect("hkdf output length is fixed to 32 bytes"); let sec_nonce = SecNonce::build(nonce_seed).build(); let pub_nonce = sec_nonce.public_nonce(); let nonce_signature = signer_keypair.sign_schnorr(get_nonce_message(&pub_nonce)); (sec_nonce, pub_nonce, nonce_signature) } -fn sha256(input: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(input); - // use encode_hex to get lowercase hex - hasher.finalize().encode_hex() -} -fn sha256_with_id(input: &str, idx: usize) -> String { - let mut hasher = Sha256::new(); - hasher.update(input); - sha256(&format!("{}{:04x}", hasher.finalize().encode_hex::(), idx)) -} diff --git a/crates/bitvm2-ga/src/keys.rs b/crates/bitvm2-ga/src/keys.rs index 39de89ec..a87ca141 100644 --- a/crates/bitvm2-ga/src/keys.rs +++ b/crates/bitvm2-ga/src/keys.rs @@ -4,23 +4,154 @@ use crate::committee::{ }; use super::{ - committee::generate_keypair_from_seed, operator::generate_wots_keys, types::{OperatorWotsPublicKeys, OperatorWotsSecretKeys}, }; -use bitcoin::key::Keypair; +use anyhow::{Context, bail}; +use bitcoin::{ + Network, PublicKey, + bip32::{ChildNumber, DerivationPath, Xpriv}, + key::Keypair, + secp256k1::{SECP256K1, SecretKey}, +}; +use chacha20poly1305::{ + ChaCha20Poly1305, KeyInit, Nonce, + aead::{Aead, Payload}, +}; +use hex::{decode as hex_decode, encode as hex_encode}; +use hkdf::Hkdf; use musig2::{PubNonce, SecNonce}; +use rand::{RngCore, rngs::OsRng}; use secp256k1::schnorr::Signature as SchnorrSignature; -use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::os::unix::fs::PermissionsExt; +use std::time::{SystemTime, UNIX_EPOCH}; +use std::{fs, io::Write, path::Path}; use uuid::Uuid; -// TODO: use safer key derivation function -fn derive_secret(master_key: &Keypair, domain: &Vec) -> String { +const HKDF_SALT: &[u8] = b"bitvm2/keys/v1"; +const BITVM_BIP32_ROOT_DOMAIN: &[u8] = b"bitvm_bip32_root"; +const PURPOSE_BITVM2_DERIVATION: u32 = 2345; + +const ROLE_COMMITTEE: u32 = 0; +const ROLE_OPERATOR: u32 = 1; +const ROLE_CHALLENGER: u32 = 2; + +const KEY_KIND_COMMITTEE_ENVELOPE: u32 = 0; +const KEY_KIND_OPERATOR_NONCE: u32 = 1; +const KEY_KIND_CHALLENGER_DISPROVE: u32 = 2; + +const COMMITTEE_ENVELOPE_VERSION: u8 = 1; + +pub fn hkdf_derive_bytes( + seed_material: &[u8], + salt: &[u8], + info: &[u8], + out_len: usize, +) -> Vec { + let hkdf = Hkdf::::new(Some(salt), seed_material); + let mut out = vec![0u8; out_len]; + hkdf.expand(info, &mut out).expect("hkdf expand with valid output length should not fail"); + out +} + +fn hkdf_expand(master_key: &Keypair, salt: &[u8], domain: &[u8], out_len: usize) -> Vec { let secret_key = master_key.secret_key(); - let mut hasher = Sha256::new(); - hasher.update(secret_key.secret_bytes()); - hasher.update(domain); - format!("{:x}", hasher.finalize()) + hkdf_derive_bytes(&secret_key.secret_bytes(), salt, domain, out_len) +} + +fn derive_secret(master_key: &Keypair, domain: &[u8]) -> String { + hex_encode(hkdf_expand(master_key, HKDF_SALT, domain, 32)) +} + +fn derive_bip32_root(master_key: &Keypair) -> Xpriv { + let seed = hkdf_expand(master_key, HKDF_SALT, BITVM_BIP32_ROOT_DOMAIN, 64); + Xpriv::new_master(Network::Bitcoin, &seed) + .expect("32-byte secp256k1 key with valid seed should derive xpriv") +} + +// Path layout: m / purpose' / role' / key_kind' / nonce_hi16' / ... / nonce_lo16' +fn operator_nonce_derivation_path(nonce: u64) -> DerivationPath { + // Split u64 nonce into four 16-bit segments + let segments = [ + ((nonce >> 48) & 0xffff) as u32, + ((nonce >> 32) & 0xffff) as u32, + ((nonce >> 16) & 0xffff) as u32, + (nonce & 0xffff) as u32, + ]; + + let mut path = vec![ + ChildNumber::from_hardened_idx(PURPOSE_BITVM2_DERIVATION).expect("constant index is valid"), + ChildNumber::from_hardened_idx(ROLE_OPERATOR).expect("constant index is valid"), + ChildNumber::from_hardened_idx(KEY_KIND_OPERATOR_NONCE).expect("constant index is valid"), + ]; + path.extend( + segments + .into_iter() + .map(|seg| ChildNumber::from_hardened_idx(seg).expect("16-bit index is always valid")), + ); + DerivationPath::from(path) +} + +// Path layout: m / purpose' / role' / key_kind' +fn challenger_disprove_derivation_path() -> DerivationPath { + DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(PURPOSE_BITVM2_DERIVATION).expect("constant index is valid"), + ChildNumber::from_hardened_idx(ROLE_CHALLENGER).expect("constant index is valid"), + ChildNumber::from_hardened_idx(KEY_KIND_CHALLENGER_DISPROVE) + .expect("constant index is valid"), + ]) +} + +// Path layout: m / purpose' / role_committee' / key_kind' / iid_0' / ... / iid_7' +fn committee_kek_derivation_path(instance_id: Uuid) -> DerivationPath { + let instance = instance_id.as_u128(); + let segments = [ + ((instance >> 112) & 0xffff) as u32, + ((instance >> 96) & 0xffff) as u32, + ((instance >> 80) & 0xffff) as u32, + ((instance >> 64) & 0xffff) as u32, + ((instance >> 48) & 0xffff) as u32, + ((instance >> 32) & 0xffff) as u32, + ((instance >> 16) & 0xffff) as u32, + (instance & 0xffff) as u32, + ]; + + let mut path = vec![ + ChildNumber::from_hardened_idx(PURPOSE_BITVM2_DERIVATION).expect("constant index is valid"), + ChildNumber::from_hardened_idx(ROLE_COMMITTEE).expect("constant index is valid"), + ChildNumber::from_hardened_idx(KEY_KIND_COMMITTEE_ENVELOPE) + .expect("constant index is valid"), + ]; + path.extend( + segments + .into_iter() + .map(|seg| ChildNumber::from_hardened_idx(seg).expect("16-bit index is always valid")), + ); + DerivationPath::from(path) +} + +fn derive_committee_instance_kek(master_key: &Keypair, instance_id: Uuid) -> ([u8; 32], String) { + let root = derive_bip32_root(master_key); + let path = committee_kek_derivation_path(instance_id); + let child = + root.derive_priv(SECP256K1, &path).expect("valid derivation path should derive child key"); + (child.private_key.secret_bytes(), path.to_string()) +} + +fn current_unix_time_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_secs() as i64 +} + +fn envelope_aad(instance_id: Uuid, committee_pubkey: &PublicKey) -> Vec { + let mut aad = Vec::with_capacity(16 + 33); + aad.extend_from_slice(instance_id.as_bytes()); + aad.extend_from_slice(&committee_pubkey.to_bytes()); + aad } pub struct NodeMasterKey(Keypair); @@ -33,7 +164,17 @@ impl NodeMasterKey { } } -// TODO: use safer key derivation function +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommitteeInstanceKeyEnvelope { + pub version: u8, + pub instance_id: Uuid, + pub committee_pubkey_hex: String, + pub kek_path: String, + pub aead_nonce_hex: String, + pub ciphertext_hex: String, + pub created_at: i64, +} + pub struct CommitteeMasterKey(Keypair); impl CommitteeMasterKey { pub fn new(inner: Keypair) -> Self { @@ -42,17 +183,156 @@ impl CommitteeMasterKey { pub fn master_keypair(&self) -> Keypair { NodeMasterKey(self.0).master_keypair() } - pub fn keypair_for_instance(&self, instance_id: Uuid) -> Keypair { - let domain = [b"committee_bitvm_key".to_vec(), instance_id.as_bytes().to_vec()].concat(); - let instance_seed = derive_secret(&self.0, &domain); - generate_keypair_from_seed(instance_seed) + + pub fn create_instance_keypair_envelope( + &self, + instance_id: Uuid, + envelope_path: &Path, + ) -> anyhow::Result { + let secret_key = SecretKey::new(&mut OsRng); + let keypair = Keypair::from_secret_key(SECP256K1, &secret_key); + let committee_pubkey: PublicKey = keypair.public_key().into(); + + let (kek, kek_path) = derive_committee_instance_kek(&self.0, instance_id); + let cipher = ChaCha20Poly1305::new_from_slice(&kek) + .expect("32-byte kek should always create chacha20poly1305 key"); + let mut nonce = [0u8; 12]; + OsRng.fill_bytes(&mut nonce); + let aad = envelope_aad(instance_id, &committee_pubkey); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { msg: &secret_key.secret_bytes(), aad: &aad }, + ) + .map_err(|e| anyhow::anyhow!("encrypt committee instance key failed: {e}"))?; + + let envelope = CommitteeInstanceKeyEnvelope { + version: COMMITTEE_ENVELOPE_VERSION, + instance_id, + committee_pubkey_hex: committee_pubkey.to_string(), + kek_path, + aead_nonce_hex: hex_encode(nonce), + ciphertext_hex: hex_encode(ciphertext), + created_at: current_unix_time_secs(), + }; + + let serialized = serde_json::to_vec_pretty(&envelope) + .context("serialize committee instance key envelope")?; + if let Some(parent) = envelope_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create envelope directory failed: {}", parent.display()) + })?; + } + let mut file = fs::File::create(envelope_path) + .with_context(|| format!("create envelope file failed: {}", envelope_path.display()))?; + file.write_all(&serialized) + .with_context(|| format!("write envelope failed: {}", envelope_path.display()))?; + fs::set_permissions(envelope_path, fs::Permissions::from_mode(0o600)).with_context( + || format!("set envelope permissions failed: {}", envelope_path.display()), + )?; + Ok(committee_pubkey) + } + + pub fn load_instance_keypair( + &self, + instance_id: Uuid, + envelope_path: &Path, + ) -> anyhow::Result { + let serialized = fs::read(envelope_path) + .with_context(|| format!("read envelope failed: {}", envelope_path.display()))?; + let envelope: CommitteeInstanceKeyEnvelope = + serde_json::from_slice(&serialized).context("parse envelope json failed")?; + if envelope.version != COMMITTEE_ENVELOPE_VERSION { + bail!( + "unsupported envelope version: {}, expected {}", + envelope.version, + COMMITTEE_ENVELOPE_VERSION + ); + } + if envelope.instance_id != instance_id { + bail!( + "envelope instance_id mismatch: expected {}, got {}", + instance_id, + envelope.instance_id + ); + } + + let committee_pubkey = envelope + .committee_pubkey_hex + .parse::() + .context("invalid envelope committee_pubkey_hex")?; + let nonce_bytes = + hex_decode(&envelope.aead_nonce_hex).context("invalid envelope nonce hex")?; + if nonce_bytes.len() != 12 { + bail!("invalid envelope nonce length: {}", nonce_bytes.len()); + } + let ciphertext = + hex_decode(&envelope.ciphertext_hex).context("invalid envelope ciphertext hex")?; + + let (kek, expected_kek_path) = derive_committee_instance_kek(&self.0, instance_id); + if envelope.kek_path != expected_kek_path { + bail!( + "envelope kek_path mismatch: expected {}, got {}", + expected_kek_path, + envelope.kek_path + ); + } + let cipher = ChaCha20Poly1305::new_from_slice(&kek) + .expect("32-byte kek should always create chacha20poly1305 key"); + let aad = envelope_aad(instance_id, &committee_pubkey); + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce_bytes), Payload { msg: &ciphertext, aad: &aad }) + .map_err(|e| anyhow::anyhow!("decrypt committee instance key failed: {e}"))?; + + if plaintext.len() != 32 { + bail!("invalid plaintext secret key length: {}", plaintext.len()); + } + let secret_key = + SecretKey::from_slice(&plaintext).context("invalid decrypted committee secret key")?; + let keypair = Keypair::from_secret_key(SECP256K1, &secret_key); + let loaded_pubkey: PublicKey = keypair.public_key().into(); + if loaded_pubkey != committee_pubkey { + bail!( + "envelope committee pubkey mismatch: expected {committee_pubkey}, got {loaded_pubkey}" + ); + } + Ok(keypair) + } + + pub fn delete_instance_keypair_envelope( + &self, + instance_id: Uuid, + envelope_path: &Path, + ) -> anyhow::Result<()> { + if !envelope_path.exists() { + return Ok(()); + } + + // Refuse deletion if caller instance id does not match envelope content. + let serialized = fs::read(envelope_path) + .with_context(|| format!("read envelope failed: {}", envelope_path.display()))?; + let envelope: CommitteeInstanceKeyEnvelope = + serde_json::from_slice(&serialized).context("parse envelope json failed")?; + if envelope.instance_id != instance_id { + bail!( + "envelope instance_id mismatch: expected {}, got {}", + instance_id, + envelope.instance_id + ); + } + + fs::remove_file(envelope_path) + .with_context(|| format!("remove envelope failed: {}", envelope_path.display()))?; + Ok(()) } - pub fn nonces_for_graph( + + pub fn nonces_for_graph_with_keypair( &self, instance_id: Uuid, graph_id: Uuid, watchtower_num: usize, assert_commit_num: usize, + signer_keypair: Keypair, ) -> (CommitteePubNonces, CommitteeSecNonces, CommitteeNonceSignatures) { let domain = [ b"committee_bitvm_graph_nonces".to_vec(), @@ -60,8 +340,7 @@ impl CommitteeMasterKey { graph_id.as_bytes().to_vec(), ] .concat(); - let nonce_seed = derive_secret(&self.0, &domain); - let signer_keypair = self.keypair_for_instance(instance_id); + let nonce_seed = derive_secret(&signer_keypair, &domain); generate_nonce_from_seed( nonce_seed, graph_id.as_u128() as usize, @@ -70,16 +349,19 @@ impl CommitteeMasterKey { assert_commit_num, ) } - pub fn nonce_for_instance(&self, instance_id: Uuid) -> (SecNonce, PubNonce, SchnorrSignature) { + + pub fn nonce_for_instance_with_keypair( + &self, + instance_id: Uuid, + signer_keypair: Keypair, + ) -> (SecNonce, PubNonce, SchnorrSignature) { let domain = [b"committee_bitvm_instance_nonce".to_vec(), instance_id.as_bytes().to_vec()].concat(); - let nonce_seed = derive_secret(&self.0, &domain); - let signer_keypair = self.keypair_for_instance(instance_id); - generate_nonce(signer_keypair, &nonce_seed, 0) + let nonce_seed = derive_secret(&signer_keypair, &domain); + generate_nonce(signer_keypair, nonce_seed.as_bytes(), 0) } } -// TODO: use safer key derivation function pub struct OperatorMasterKey(Keypair); impl OperatorMasterKey { pub fn new(inner: Keypair) -> Self { @@ -89,9 +371,12 @@ impl OperatorMasterKey { NodeMasterKey(self.0).master_keypair() } pub fn keypair_for_nonce(&self, nonce: u64) -> Keypair { - let domain = [b"operator_bitvm_key".to_vec(), nonce.to_be_bytes().to_vec()].concat(); - let nonce_seed = derive_secret(&self.0, &domain); - generate_keypair_from_seed(nonce_seed) + let root = derive_bip32_root(&self.0); + let path = operator_nonce_derivation_path(nonce); + let child = root + .derive_priv(SECP256K1, &path) + .expect("valid derivation path should derive child key"); + Keypair::from_secret_key(SECP256K1, &child.private_key) } pub fn wots_keypair_for_graph( &self, @@ -102,11 +387,13 @@ impl OperatorMasterKey { generate_wots_keys(&wot_seed) } pub fn preimage_for_graph(&self, graph_id: Uuid, index: usize) -> Vec { - let domain = [b"operator_bitvm_preimage".to_vec(), graph_id.as_bytes().to_vec()].concat(); - let preimage_seed = derive_secret(&self.0, &domain); - let mut hasher = Sha256::new(); - hasher.update(format!("{preimage_seed}/{index:04x}").as_bytes()); - hasher.finalize().to_vec() + let domain = [ + b"operator_bitvm_preimage".to_vec(), + graph_id.as_bytes().to_vec(), + index.to_be_bytes().to_vec(), + ] + .concat(); + hkdf_expand(&self.0, HKDF_SALT, &domain, 32) } } @@ -118,6 +405,14 @@ impl ChallengerMasterKey { pub fn master_keypair(&self) -> Keypair { NodeMasterKey(self.0).master_keypair() } + pub fn keypair_for_nst_disprove(&self) -> Keypair { + let root = derive_bip32_root(&self.0); + let path = challenger_disprove_derivation_path(); + let child = root + .derive_priv(SECP256K1, &path) + .expect("valid derivation path should derive child key"); + Keypair::from_secret_key(SECP256K1, &child.private_key) + } } pub struct WatchtowerMasterKey(Keypair); @@ -129,3 +424,102 @@ impl WatchtowerMasterKey { NodeMasterKey(self.0).master_keypair() } } + +#[cfg(test)] +mod tests { + use super::*; + use sha2::Digest; + use std::path::PathBuf; + + fn test_master_keypair(seed: &str) -> Keypair { + let hashed = Sha256::digest(seed.as_bytes()); + let sk = SecretKey::from_slice(&hashed).expect("seeded hash should build secret key"); + Keypair::from_secret_key(SECP256K1, &sk) + } + + fn test_envelope_path(instance_id: Uuid) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("bitvm2-committee-key-{instance_id}.json")); + path + } + + #[test] + fn committee_instance_key_envelope_create_load_delete() { + let instance_id = Uuid::new_v4(); + let master = CommitteeMasterKey::new(test_master_keypair("seed:test-committee-master")); + let envelope_path = test_envelope_path(instance_id); + + if envelope_path.exists() { + fs::remove_file(&envelope_path).expect("cleanup stale envelope"); + } + + let created_pubkey = master + .create_instance_keypair_envelope(instance_id, &envelope_path) + .expect("create envelope should succeed"); + assert!(envelope_path.exists(), "envelope file should exist after create"); + + let loaded = master + .load_instance_keypair(instance_id, &envelope_path) + .expect("load envelope should succeed"); + let loaded_pubkey: PublicKey = loaded.public_key().into(); + assert_eq!(created_pubkey, loaded_pubkey, "loaded keypair must match created pubkey"); + + master + .delete_instance_keypair_envelope(instance_id, &envelope_path) + .expect("delete envelope should succeed"); + assert!(!envelope_path.exists(), "envelope file should be removed"); + } + + #[test] + fn committee_instance_key_envelope_load_mismatch_instance_id_should_fail() { + let instance_id = Uuid::new_v4(); + let wrong_instance_id = Uuid::new_v4(); + let master = CommitteeMasterKey::new(test_master_keypair("seed:test-committee-master")); + let envelope_path = test_envelope_path(instance_id); + + if envelope_path.exists() { + fs::remove_file(&envelope_path).expect("cleanup stale envelope"); + } + + master + .create_instance_keypair_envelope(instance_id, &envelope_path) + .expect("create envelope should succeed"); + + let err = master + .load_instance_keypair(wrong_instance_id, &envelope_path) + .expect_err("load with mismatched instance_id should fail"); + assert!( + err.to_string().contains("instance_id mismatch"), + "error should include mismatch hint" + ); + + fs::remove_file(&envelope_path).expect("cleanup envelope after test"); + } + + #[test] + fn committee_instance_key_envelope_delete_mismatch_instance_id_should_fail() { + let instance_id = Uuid::new_v4(); + let wrong_instance_id = Uuid::new_v4(); + let master = CommitteeMasterKey::new(test_master_keypair("seed:test-committee-master")); + let envelope_path = test_envelope_path(instance_id); + + if envelope_path.exists() { + fs::remove_file(&envelope_path).expect("cleanup stale envelope"); + } + + master + .create_instance_keypair_envelope(instance_id, &envelope_path) + .expect("create envelope should succeed"); + + let err = master + .delete_instance_keypair_envelope(wrong_instance_id, &envelope_path) + .expect_err("delete with mismatched instance_id should fail"); + assert!( + err.to_string().contains("instance_id mismatch"), + "error should include mismatch hint" + ); + assert!(envelope_path.exists(), "envelope file must remain when delete is rejected"); + + fs::remove_file(&envelope_path).expect("cleanup envelope after test"); + } +} diff --git a/crates/bitvm2-ga/src/operator/api.rs b/crates/bitvm2-ga/src/operator/api.rs index 93cfb25c..60a0fe86 100644 --- a/crates/bitvm2-ga/src/operator/api.rs +++ b/crates/bitvm2-ga/src/operator/api.rs @@ -1,3 +1,4 @@ +use crate::keys::hkdf_derive_bytes; use crate::types::{ Bitvm2Graph, Bitvm2GraphParameters, Groth16Proof, OperatorWotsPublicKeys, OperatorWotsSecretKeys, OperatorWotsSignatures, PublicInputs, VerifyingKey, @@ -50,7 +51,9 @@ use goat::transactions::watchtower_challenge::{ WatchtowerChallengeTimeoutTransaction, operator_ack, operator_commit_blockhash, }; use goat::utils::num_blocks_per_network; -use sha2::{Digest, Sha256}; +use hex::encode as hex_encode; + +const OPERATOR_WOTS_HKDF_SALT: &[u8] = b"bitvm2/operator-wots/v1"; pub fn generate_wots_keys(seed: &str) -> (OperatorWotsSecretKeys, OperatorWotsPublicKeys) { let secrets = wots_seed_to_secrets(seed); @@ -101,30 +104,27 @@ pub fn wots_secrets_to_pubkeys(secrets: &OperatorWotsSecretKeys) -> OperatorWots #[allow(deprecated)] pub fn wots_seed_to_secrets(seed: &str) -> OperatorWotsSecretKeys { - fn sha256(input: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(input); - format!("{:x}", hasher.finalize()) - } - fn sha256_with_id(input: &str, idx: usize) -> String { - let mut hasher = Sha256::new(); - hasher.update(input); - sha256(&format!("{:x}{:04x}", hasher.finalize(), idx)) - } - - let seed_hash = sha256(seed); + let seed_bytes = seed.as_bytes(); let wot32_seckeys = (0..NUM_GUEST + NUM_PUBS + NUM_U256) .map(|idx| { - let sec_i = sha256_with_id(&seed_hash, 1); - let sec_i = sha256_with_id(&sec_i, idx); + let sec_i = hex_encode(hkdf_derive_bytes( + seed_bytes, + OPERATOR_WOTS_HKDF_SALT, + format!("wots32/{idx}").as_bytes(), + 32, + )); let sec_str = format!("{sec_i}{:04x}{:04x}", 1, idx); Wots32::secret_from_str(&sec_str) }) .collect::>(); let wot16_seckeys = (0..NUM_HASH) .map(|idx| { - let sec_i = sha256_with_id(&seed_hash, 2); - let sec_i = sha256_with_id(&sec_i, idx); + let sec_i = hex_encode(hkdf_derive_bytes( + seed_bytes, + OPERATOR_WOTS_HKDF_SALT, + format!("wots16/{idx}").as_bytes(), + 32, + )); let sec_str = format!("{sec_i}{:04x}{:04x}", 0, idx); Wots16::secret_from_str(&sec_str) }) diff --git a/node/src/env.rs b/node/src/env.rs index 34d718cc..2b924555 100644 --- a/node/src/env.rs +++ b/node/src/env.rs @@ -62,6 +62,7 @@ pub const ENV_ACTOR: &str = "ACTOR"; pub const ENV_IPFS_ENDPOINT: &str = "IPFS_ENDPOINT"; pub const ENV_COMMITTEE_NUM: &str = "COMMITTEE_NUM"; pub const ENV_EXTERNAL_SOCKET_ADDR: &str = "EXTERNAL_SOCKET_ADDR"; +pub const COMMITTEE_INSTANCE_KEYS_DIR: &str = "cache/committee-instance-keys/"; pub const SCRIPT_CACHE_FILE_NAME: &str = "cache/partial_script.bin"; pub const ASSERT_COMMITS_CACHE_DIR: &str = "cache/assert_commits_cache/"; pub const IPFS_GRAPH_CACHE_DIR: &str = "cache/graph_cache/"; diff --git a/node/src/handle.rs b/node/src/handle.rs index 28720d70..b34a3eb2 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -1,10 +1,12 @@ use crate::action::*; -use crate::env::{get_bitvm_key, get_network, get_node_goat_address, is_relayer}; +use crate::env::{ + COMMITTEE_INSTANCE_KEYS_DIR, get_bitvm_key, get_network, get_node_goat_address, is_relayer, +}; use crate::error::SpecialError; use crate::middleware::AllBehaviours; use crate::scheduled_tasks::graph_maintenance_tasks::ChallengeSubStatus; use crate::utils::*; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Context, Result, anyhow, bail}; use bitcoin::hashes::Hash; use bitcoin::{OutPoint, Txid}; use bitcoin::{PublicKey, XOnlyPublicKey}; @@ -39,6 +41,62 @@ pub struct HandlerContext<'a> { pub is_self_peer: bool, } +fn committee_instance_keys_envelope_path(instance_id: Uuid) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(COMMITTEE_INSTANCE_KEYS_DIR); + path.push(format!("{instance_id}.json")); + path +} + +fn load_committee_instance_keypair( + committee_master_key: &CommitteeMasterKey, + instance_id: Uuid, +) -> Result { + let envelope_path = committee_instance_keys_envelope_path(instance_id); + committee_master_key.load_instance_keypair(instance_id, &envelope_path).with_context(|| { + format!( + "load committee instance keypair failed for {} at {}", + instance_id, + envelope_path.display() + ) + }) +} + +fn load_or_create_committee_instance_keypair( + committee_master_key: &CommitteeMasterKey, + instance_id: Uuid, +) -> Result { + let envelope_path = committee_instance_keys_envelope_path(instance_id); + match committee_master_key.load_instance_keypair(instance_id, &envelope_path) { + Ok(keypair) => Ok(keypair), + Err(load_err) => { + tracing::info!( + "committee instance key not found/invalid for {} at {}: {}, creating new envelope", + instance_id, + envelope_path.display(), + load_err + ); + committee_master_key + .create_instance_keypair_envelope(instance_id, &envelope_path) + .with_context(|| { + format!( + "create committee instance key envelope failed for {} at {}", + instance_id, + envelope_path.display() + ) + })?; + committee_master_key.load_instance_keypair(instance_id, &envelope_path).with_context( + || { + format!( + "reload committee instance key envelope failed for {} at {}", + instance_id, + envelope_path.display() + ) + }, + ) + } + } +} + pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent) -> Result<()> { match (content, &ctx.actor) { ( @@ -709,10 +767,10 @@ async fn handle_pegin_request_committee( ) .await?; // 3. call Gateway.answerPeginRequest - let pubkey_for_instance = CommitteeMasterKey::new(get_bitvm_key()?) - .keypair_for_instance(instance_id) - .public_key() - .into(); + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = + load_or_create_committee_instance_keypair(&committee_master_key, instance_id)?; + let pubkey_for_instance = instance_keypair.public_key().into(); ctx.goat_client.gateway_answer_pegin_request(&instance_id, &pubkey_for_instance).await?; Ok(()) } @@ -887,14 +945,15 @@ async fn handle_create_graph_committee( store_graph(ctx.local_db, graph).await?; // 3. generate Musig2 nonces & broadcast NonceGeneration let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let (pub_nonces, _, nonce_sigs) = committee_master_key.nonces_for_graph( + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let (pub_nonces, _, nonce_sigs) = committee_master_key.nonces_for_graph_with_keypair( instance_id, graph_id, graph.parameters.watchtower_pubkeys.len(), graph.assert_commit_num, + instance_keypair, ); - let local_committee_pubkey = - committee_master_key.keypair_for_instance(instance_id).public_key().into(); + let local_committee_pubkey = instance_keypair.public_key().into(); let message_content = GOATMessageContent::NonceGeneration(NonceGeneration { instance_id, graph_id, @@ -931,18 +990,16 @@ async fn handle_create_graph_committee( } let agg_nonces = nonces_aggregation(&pub_nonces)?; let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let (_, sec_nonces, _) = committee_master_key.nonces_for_graph( + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let (_, sec_nonces, _) = committee_master_key.nonces_for_graph_with_keypair( instance_id, graph_id, watchtower_num, assert_commit_num, + instance_keypair, ); - let committee_partial_sigs = committee_pre_sign( - committee_master_key.keypair_for_instance(instance_id), - sec_nonces, - agg_nonces.clone(), - &mut graph, - )?; + let committee_partial_sigs = + committee_pre_sign(instance_keypair, sec_nonces, agg_nonces.clone(), &mut graph)?; let message_content = GOATMessageContent::CommitteePresign(CommitteePresign { instance_id, graph_id, @@ -1010,10 +1067,9 @@ async fn handle_nonce_generation_committee( let pub_nonces_unchecked = get_committee_pub_nonces_for_graph(ctx.local_db, instance_id, graph_id).await?; if pub_nonces_unchecked.len() == committee_pubkeys.len() { - let local_committee_pubkey = CommitteeMasterKey::new(get_bitvm_key()?) - .keypair_for_instance(instance_id) - .public_key() - .into(); + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); let message = make_message(ctx, content); let graph = match get_graph_or_defer( ctx.swarm, @@ -1040,20 +1096,16 @@ async fn handle_nonce_generation_committee( pub_nonces.push(pn); } let agg_nonces = nonces_aggregation(&pub_nonces)?; - let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let (_, sec_nonces, _) = committee_master_key.nonces_for_graph( + let (_, sec_nonces, _) = committee_master_key.nonces_for_graph_with_keypair( instance_id, graph_id, watchtower_num, assert_commit_num, + instance_keypair, ); // 4. if received enough valid committee partial sigs, endorse the graph - let committee_partial_sigs = committee_pre_sign( - committee_master_key.keypair_for_instance(instance_id), - sec_nonces, - agg_nonces.clone(), - &mut graph, - )?; + let committee_partial_sigs = + committee_pre_sign(instance_keypair, sec_nonces, agg_nonces.clone(), &mut graph)?; let message_content = GOATMessageContent::CommitteePresign(CommitteePresign { instance_id, graph_id, @@ -1234,10 +1286,9 @@ async fn handle_committee_presign_committee( }; let graph = Bitvm2Graph::from_simplified(&graph)?; let committee_sig_for_graph = endorse_graph(ctx.goat_client, &graph).await?; - let local_committee_pubkey = CommitteeMasterKey::new(get_bitvm_key()?) - .keypair_for_instance(instance_id) - .public_key() - .into(); + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); let committee_evm_address = get_node_goat_address() .ok_or_else(|| anyhow::anyhow!("failed to get node goat address".to_string()))?; let message_content = GOATMessageContent::EndorseGraph(EndorseGraph { @@ -1404,8 +1455,8 @@ async fn handle_graph_finalize_committee( >= todo_funcs::min_required_operator() { let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let local_committee_pubkey = - committee_master_key.keypair_for_instance(instance_id).public_key().into(); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); let stored_pub_nonce = get_committee_pub_nonce_for_instance( ctx.local_db, instance_id, @@ -1413,7 +1464,8 @@ async fn handle_graph_finalize_committee( ) .await?; if stored_pub_nonce.is_none() { - let (_, pub_nonce, nonce_sig) = committee_master_key.nonce_for_instance(instance_id); + let (_, pub_nonce, nonce_sig) = + committee_master_key.nonce_for_instance_with_keypair(instance_id, instance_keypair); let message_content = GOATMessageContent::PeginConfirmNonce(PeginConfirmNonce { instance_id, committee_pubkey: local_committee_pubkey, @@ -1523,17 +1575,17 @@ async fn handle_pegin_confirm_nonce_committee( let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; if pub_nonces.len() == committee_pubkeys.len() { let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let local_committee_pubkey = - committee_master_key.keypair_for_instance(instance_id).public_key().into(); - let (sec_nonce, _, _) = committee_master_key.nonce_for_instance(instance_id); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); + let (sec_nonce, _, _) = + committee_master_key.nonce_for_instance_with_keypair(instance_id, instance_keypair); let agg_nonce = nonce_aggregation(&pub_nonces.iter().map(|(_, pn)| pn.clone()).collect::>()); let instance_params = get_instance_parameters(ctx.local_db, instance_id) .await? .ok_or_else(|| anyhow!("Instance parameters not found for {instance_id}"))?; let mut pegin_confirm = instance_params.build_pegin_tx()?.1; - let context = instance_params - .get_verifier_context(committee_master_key.keypair_for_instance(instance_id))?; + let context = instance_params.get_verifier_context(instance_keypair)?; let partial_sig = pegin_confirm .sign_input_0_musig2(&context, &sec_nonce, &agg_nonce) .map_err(|e| anyhow!("Failed to sign pegin confirm for {instance_id}: {e}"))?; From 5c542417f96e000623f33b4775c6dfa7e2a8d7a5 Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:46:29 +0800 Subject: [PATCH 2/8] use separate address for nst (disprove) broadcast --- node/src/handle.rs | 54 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/node/src/handle.rs b/node/src/handle.rs index b34a3eb2..693ec45d 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -3235,14 +3235,58 @@ async fn handle_disprove_ready_challenger( Some(*disprover_evm_address.as_ref()), )?; let challenger_master_key = ChallengerMasterKey::new(get_bitvm_key()?); - let challenger_master_keypair = challenger_master_key.master_keypair(); - build_sign_and_broadcast_non_standard_tx( + let challenger_disprove_keypair = challenger_master_key.keypair_for_nst_disprove(); + if let Err(e) = build_sign_and_broadcast_non_standard_tx( ctx.btc_client, - challenger_master_keypair, - disprove_tx, + challenger_disprove_keypair, + disprove_tx.clone(), connector_e_input.amount, ) - .await?; + .await + { + if e.downcast_ref::() + .is_some_and(|se| matches!(se, SpecialError::InsufficientBalance(_))) + { + let disprove_address = node_p2wsh_address( + get_network(), + &challenger_disprove_keypair.public_key().into(), + ); + let disprove_balance = ctx + .btc_client + .get_address_utxo(disprove_address.clone()) + .await? + .iter() + .map(|u| u.value) + .sum::(); + let fee_rate = get_fee_rate(ctx.btc_client).await?; + let est_fee_sat = + ((disprove_tx.weight().to_vbytes_ceil() + 200) as f64 * fee_rate).ceil() as u64; + let target_balance_sat = est_fee_sat + 20_000; + let shortfall_sat = target_balance_sat.saturating_sub(disprove_balance.to_sat()); + if shortfall_sat > 0 { + tracing::info!( + "Top up nst-disprove p2wsh address for {instance_id}:{graph_id}: shortfall={} sats", + shortfall_sat + ); + fund_address( + ctx.btc_client, + challenger_master_key.master_keypair(), + disprove_address, + bitcoin::Amount::from_sat(shortfall_sat), + ) + .await?; + } + build_sign_and_broadcast_non_standard_tx( + ctx.btc_client, + challenger_disprove_keypair, + disprove_tx, + connector_e_input.amount, + ) + .await?; + } else { + return Err(e); + } + } } else { tracing::info!("All assertions valid for {instance_id}:{graph_id}, no need to disprove"); return Ok(()); From b7e266f7b24a21793d4c8705f9c8d0b156c42d6f Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:19:37 +0800 Subject: [PATCH 3/8] add instance_committee_key_cleanup_monitor --- node/README.md | 6 + node/src/env.rs | 20 ++++ .../instance_maintenance_tasks.rs | 113 +++++++++++++++++- node/src/scheduled_tasks/mod.rs | 7 +- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/node/README.md b/node/README.md index ce54f0eb..954ce840 100644 --- a/node/README.md +++ b/node/README.md @@ -710,6 +710,7 @@ flowchart TB IM2["instance_window_expiration_monitor"] IM3["instance_btc_tx_monitor"] IM4["instance_bridge_out_monitor"] + IM5["instance_committee_key_cleanup_monitor"] end subgraph Other["Other Tasks"] @@ -734,6 +735,9 @@ flowchart TB | `process_graph_challenge` | 20s | Process Challenge sub-phases | | `instance_answers_monitor` | 20s | Track committee responses | | `instance_window_expiration_monitor` | 20s | Handle response window timeouts | +| `instance_btc_tx_monitor` | 20s | Track pegin/confirm/cancel BTC transaction confirmations | +| `instance_bridge_out_monitor` | 20s | Track bridge-out deadlines and timeout transitions | +| `instance_committee_key_cleanup_monitor` | 20s | Scan `cache/committee-instance-keys/` and delete expired key envelopes after configurable pegin-confirm timelock | | `spv_header_hash_update` | Periodic | Update SPV header hashes | --- @@ -851,6 +855,8 @@ bitvm2-noded \ | `GOAT_PROOF_BUILD_URL` | No | Proof Builder RPC endpoint | - | | `NODE_NAME` | No | Node display name | `ZKM` | | `OPERATOR_NODE_SERVICE_FEE` | No | Operator service fee rate | `0.001` | +| `ENABLE_COMMITTEE_INSTANCE_KEY_DELETE` | No | Enable scheduled deletion of committee instance key envelopes | `true` | +| `COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS` | No | Number of BTC blocks to wait after pegin-confirm confirmation before deleting key envelope | `32` | ### Relayer Configuration diff --git a/node/src/env.rs b/node/src/env.rs index 2b924555..40b56dbd 100644 --- a/node/src/env.rs +++ b/node/src/env.rs @@ -135,6 +135,11 @@ pub const ENV_INSTANCE_MAINTENANCE_BATCH_SIZE: &str = "INSTANCE_MAINTENANCE_BATC pub const DEFAULT_INSTANCE_MAINTENANCE_BATCH_SIZE: u32 = 50; pub const ENV_MAINTENANCE_RUN_TIMEOUT_SECS: &str = "MAINTENANCE_RUN_TIMEOUT_SECS"; pub const DEFAULT_MAINTENANCE_RUN_TIMEOUT_SECS: u64 = 60; +pub const ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE: &str = "ENABLE_COMMITTEE_INSTANCE_KEY_DELETE"; +pub const DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE: bool = true; +pub const ENV_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS: &str = + "COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS"; +pub const DEFAULT_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS: i64 = 32; pub fn get_network() -> Network { let network = std::env::var(ENV_BITCOIN_NETWORK).unwrap_or("testnet4".to_string()); @@ -568,6 +573,21 @@ pub fn get_maintenance_run_timeout_secs() -> u64 { .unwrap_or(DEFAULT_MAINTENANCE_RUN_TIMEOUT_SECS) } +pub fn is_enable_committee_instance_key_delete() -> bool { + std::env::var(ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) + .ok() + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) +} + +pub fn get_committee_instance_key_delete_timelock_blocks() -> i64 { + std::env::var(ENV_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|v| v.clamp(1, 200_000)) + .unwrap_or(DEFAULT_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS) +} + pub fn should_always_challenge() -> bool { match std::env::var(ENV_ALWAYS_CHALLENGE) { Ok(val) => val.to_lowercase() == "true", diff --git a/node/src/scheduled_tasks/instance_maintenance_tasks.rs b/node/src/scheduled_tasks/instance_maintenance_tasks.rs index 31ce1d1d..f2bd4071 100644 --- a/node/src/scheduled_tasks/instance_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/instance_maintenance_tasks.rs @@ -1,5 +1,9 @@ use crate::action::{ConfirmInstance, GOATMessageContent, PeginRequest, PostReady}; -use crate::env::{INSTANCE_PRESIGNED_TIME_EXPIRED, get_instance_maintenance_batch_size}; +use crate::env::{ + COMMITTEE_INSTANCE_KEYS_DIR, INSTANCE_PRESIGNED_TIME_EXPIRED, get_bitvm_key, + get_committee_instance_key_delete_timelock_blocks, get_instance_maintenance_batch_size, + is_enable_committee_instance_key_delete, +}; use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::event_watch_task::generate_instance_from_bridge_in_request_event; use crate::scheduled_tasks::get_timestamp_from_contract_data; @@ -11,12 +15,15 @@ use crate::utils::{ use alloy::sol_types::SolType; use bitvm2_lib::actors::Actor; use bitvm2_lib::constants::CONNECTOR_Z_TIMELOCK; +use bitvm2_lib::keys::CommitteeMasterKey; use bitvm2_lib::transactions::base::BaseTransaction; use client::Utxo; use client::btc_chain::BTCClient; use client::goat_chain::GOATClient; use client::graphs::graph_query::BridgeInRequestEvent; use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; use std::str::FromStr; use std::sync::{LazyLock, Mutex}; use std::vec; @@ -508,3 +515,107 @@ pub async fn instance_bridge_out_monitor(local_db: &LocalDB) -> anyhow::Result<( Ok(()) } + +fn list_committee_instance_key_envelopes() -> anyhow::Result> { + let dir = PathBuf::from(COMMITTEE_INSTANCE_KEYS_DIR); + if !dir.exists() { + return Ok(vec![]); + } + + let mut envelopes = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + warn!("invalid committee key envelope filename: {}", path.display()); + continue; + }; + match Uuid::parse_str(stem) { + Ok(instance_id) => envelopes.push((instance_id, path)), + Err(err) => { + warn!("skip non-instance envelope file {}: {}", path.display(), err); + } + } + } + Ok(envelopes) +} + +pub async fn instance_committee_key_cleanup_monitor( + local_db: &LocalDB, + btc_client: &BTCClient, +) -> anyhow::Result<()> { + if !is_enable_committee_instance_key_delete() { + return Ok(()); + } + + let envelopes = list_committee_instance_key_envelopes()?; + if envelopes.is_empty() { + return Ok(()); + } + + let current_height = btc_client.get_height().await? as i64; + let delete_timelock_blocks = get_committee_instance_key_delete_timelock_blocks(); + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let mut storage_processor = local_db.acquire().await?; + + for (instance_id, envelope_path) in envelopes { + let Some(instance) = storage_processor.find_instance(&instance_id).await? else { + warn!( + "committee key envelope exists but instance missing: {} ({})", + instance_id, + envelope_path.display() + ); + continue; + }; + + let Some(pegin_confirm_txid) = instance.pegin_confirm_txid else { + continue; + }; + + let tx_status = match btc_client.get_tx_status(&pegin_confirm_txid.0).await { + Ok(v) => v, + Err(err) => { + warn!( + "failed to query pegin-confirm tx status for instance {}: {}", + instance_id, err + ); + continue; + } + }; + if !tx_status.confirmed { + continue; + } + let Some(confirmed_height) = tx_status.block_height else { + continue; + }; + if (confirmed_height as i64 + delete_timelock_blocks) > current_height { + continue; + } + + match committee_master_key.delete_instance_keypair_envelope(instance_id, &envelope_path) { + Ok(()) => { + info!( + "deleted committee instance key envelope for {} at {}", + instance_id, + envelope_path.display() + ); + } + Err(err) => { + warn!( + "failed to delete committee instance key envelope for {} at {}: {}", + instance_id, + envelope_path.display(), + err + ); + } + } + } + + Ok(()) +} diff --git a/node/src/scheduled_tasks/mod.rs b/node/src/scheduled_tasks/mod.rs index 217361ec..710bd88c 100644 --- a/node/src/scheduled_tasks/mod.rs +++ b/node/src/scheduled_tasks/mod.rs @@ -12,7 +12,8 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ }; use crate::scheduled_tasks::instance_maintenance_tasks::{ instance_answers_monitor, instance_bridge_out_monitor, instance_btc_tx_monitor, - instance_expiration_monitor, instance_window_expiration_monitor, + instance_committee_key_cleanup_monitor, instance_expiration_monitor, + instance_window_expiration_monitor, }; use crate::scheduled_tasks::node_maintenance_tasks::node_available_pbtc_update_monitor; use crate::scheduled_tasks::spv_maintenance_tasks::spv_header_hash_update; @@ -86,6 +87,10 @@ async fn run( warn!("instance_btc_tx_monitor, err {:?}", err) } + if let Err(err) = instance_committee_key_cleanup_monitor(local_db, btc_client).await { + warn!("instance_committee_key_cleanup_monitor, err {:?}", err) + } + if let Err(err) = instance_bridge_out_monitor(local_db).await { warn!("instance_bridge_out_monitor, err {:?}", err) } From 99c80035a31cd07a2a6bab01e2a2f10e61a5a96a Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:02:51 +0800 Subject: [PATCH 4/8] add some tests for keys --- crates/bitvm2-ga/src/keys.rs | 90 ++++++++++++++++++++++++++++++++++++ node/README.md | 2 +- node/src/env.rs | 15 ++++-- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/bitvm2-ga/src/keys.rs b/crates/bitvm2-ga/src/keys.rs index a87ca141..246a3cf7 100644 --- a/crates/bitvm2-ga/src/keys.rs +++ b/crates/bitvm2-ga/src/keys.rs @@ -522,4 +522,94 @@ mod tests { fs::remove_file(&envelope_path).expect("cleanup envelope after test"); } + + #[test] + fn committee_instance_key_envelope_delete_nonexistent_is_ok() { + let instance_id = Uuid::new_v4(); + let master = CommitteeMasterKey::new(test_master_keypair("seed:test-committee-master")); + let envelope_path = test_envelope_path(instance_id); + + if envelope_path.exists() { + fs::remove_file(&envelope_path).expect("cleanup stale envelope"); + } + + master + .delete_instance_keypair_envelope(instance_id, &envelope_path) + .expect("delete should be idempotent when file does not exist"); + } + + #[test] + fn hkdf_derive_bytes_is_deterministic_and_respects_output_length() { + let seed = b"seed-material"; + let salt = b"salt-material"; + let info = b"derive/test"; + + let out1 = hkdf_derive_bytes(seed, salt, info, 32); + let out2 = hkdf_derive_bytes(seed, salt, info, 32); + let out3 = hkdf_derive_bytes(seed, salt, b"derive/other", 32); + + assert_eq!(out1.len(), 32); + assert_eq!(out1, out2, "same inputs must derive identical output"); + assert_ne!(out1, out3, "different info should derive different output"); + } + + #[test] + fn operator_keypair_for_nonce_is_deterministic_and_nonce_scoped() { + let master = OperatorMasterKey::new(test_master_keypair("seed:test-operator-master")); + + let keypair_a1 = master.keypair_for_nonce(42); + let keypair_a2 = master.keypair_for_nonce(42); + let keypair_b = master.keypair_for_nonce(43); + + let pub_a1: PublicKey = keypair_a1.public_key().into(); + let pub_a2: PublicKey = keypair_a2.public_key().into(); + let pub_b: PublicKey = keypair_b.public_key().into(); + + assert_eq!(pub_a1, pub_a2, "same nonce should derive same keypair"); + assert_ne!(pub_a1, pub_b, "different nonce should derive different keypairs"); + } + + #[test] + fn operator_nonce_derivation_path_has_expected_bip32_layout() { + let nonce: u64 = 0x1122_3344_5566_7788; + let path = operator_nonce_derivation_path(nonce); + let children: Vec = path.into_iter().cloned().collect(); + + assert_eq!(children.len(), 7, "path should be purpose/role/key_kind + 4 segments"); + assert_eq!(children[0], ChildNumber::from_hardened_idx(PURPOSE_BITVM2_DERIVATION).unwrap()); + assert_eq!(children[1], ChildNumber::from_hardened_idx(ROLE_OPERATOR).unwrap()); + assert_eq!(children[2], ChildNumber::from_hardened_idx(KEY_KIND_OPERATOR_NONCE).unwrap()); + assert_eq!(children[3], ChildNumber::from_hardened_idx(0x1122).unwrap()); + assert_eq!(children[4], ChildNumber::from_hardened_idx(0x3344).unwrap()); + assert_eq!(children[5], ChildNumber::from_hardened_idx(0x5566).unwrap()); + assert_eq!(children[6], ChildNumber::from_hardened_idx(0x7788).unwrap()); + } + + #[test] + fn committee_kek_derivation_is_deterministic_and_instance_scoped() { + let master = test_master_keypair("seed:test-committee-master"); + let instance_a = Uuid::new_v4(); + let instance_b = Uuid::new_v4(); + + let (kek_a1, path_a1) = derive_committee_instance_kek(&master, instance_a); + let (kek_a2, path_a2) = derive_committee_instance_kek(&master, instance_a); + let (kek_b, path_b) = derive_committee_instance_kek(&master, instance_b); + + assert_eq!(kek_a1, kek_a2, "same instance should derive same kek bytes"); + assert_eq!(path_a1, path_a2, "same instance should derive same path"); + assert_ne!(kek_a1, kek_b, "different instances should derive different kek"); + assert_ne!(path_a1, path_b, "different instances should derive different path"); + } + + #[test] + fn challenger_nst_disprove_keypair_is_deterministic() { + let master = ChallengerMasterKey::new(test_master_keypair("seed:test-challenger-master")); + + let keypair_1 = master.keypair_for_nst_disprove(); + let keypair_2 = master.keypair_for_nst_disprove(); + + let pub_1: PublicKey = keypair_1.public_key().into(); + let pub_2: PublicKey = keypair_2.public_key().into(); + assert_eq!(pub_1, pub_2, "challenger disprove keypair should be stable"); + } } diff --git a/node/README.md b/node/README.md index 954ce840..d165f3d5 100644 --- a/node/README.md +++ b/node/README.md @@ -855,7 +855,7 @@ bitvm2-noded \ | `GOAT_PROOF_BUILD_URL` | No | Proof Builder RPC endpoint | - | | `NODE_NAME` | No | Node display name | `ZKM` | | `OPERATOR_NODE_SERVICE_FEE` | No | Operator service fee rate | `0.001` | -| `ENABLE_COMMITTEE_INSTANCE_KEY_DELETE` | No | Enable scheduled deletion of committee instance key envelopes | `true` | +| `ENABLE_COMMITTEE_INSTANCE_KEY_DELETE` | No | Enable scheduled deletion of committee instance key envelopes | `false` | | `COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS` | No | Number of BTC blocks to wait after pegin-confirm confirmation before deleting key envelope | `32` | ### Relayer Configuration diff --git a/node/src/env.rs b/node/src/env.rs index 40b56dbd..dd2b8877 100644 --- a/node/src/env.rs +++ b/node/src/env.rs @@ -136,7 +136,7 @@ pub const DEFAULT_INSTANCE_MAINTENANCE_BATCH_SIZE: u32 = 50; pub const ENV_MAINTENANCE_RUN_TIMEOUT_SECS: &str = "MAINTENANCE_RUN_TIMEOUT_SECS"; pub const DEFAULT_MAINTENANCE_RUN_TIMEOUT_SECS: u64 = 60; pub const ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE: &str = "ENABLE_COMMITTEE_INSTANCE_KEY_DELETE"; -pub const DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE: bool = true; +pub const DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE: bool = false; pub const ENV_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS: &str = "COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS"; pub const DEFAULT_COMMITTEE_INSTANCE_KEY_DELETE_TIMELOCK_BLOCKS: i64 = 32; @@ -574,10 +574,15 @@ pub fn get_maintenance_run_timeout_secs() -> u64 { } pub fn is_enable_committee_instance_key_delete() -> bool { - std::env::var(ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) - .ok() - .map(|value| value.eq_ignore_ascii_case("true")) - .unwrap_or(DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) + // TODO: enable this feature when ready + tracing::warn!( + "Committee key delete not activated, ignore ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE" + ); + false + // std::env::var(ENV_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) + // .ok() + // .map(|value| value.eq_ignore_ascii_case("true")) + // .unwrap_or(DEFAULT_ENABLE_COMMITTEE_INSTANCE_KEY_DELETE) } pub fn get_committee_instance_key_delete_timelock_blocks() -> i64 { From 1082d6bc91f4f7420ea30baa49885f531c1599f1 Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:43:13 +0800 Subject: [PATCH 5/8] fix corrupt_proof (test only func) --- crates/bitvm2-ga/src/operator/api.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/bitvm2-ga/src/operator/api.rs b/crates/bitvm2-ga/src/operator/api.rs index 60a0fe86..b556e2ee 100644 --- a/crates/bitvm2-ga/src/operator/api.rs +++ b/crates/bitvm2-ga/src/operator/api.rs @@ -169,20 +169,21 @@ pub fn corrupt_proof( let mut scramble2: [u8; HASH_LEN] = [1u8; HASH_LEN]; scramble2[HASH_LEN / 2] = 37; println!("corrupted assertion at index {index}"); + let sec_index = index + NUM_GUEST; if index < NUM_PUBS { let i = index; let assn = scramble; - let sig = Wots32::sign(&wots_sec[index], &assn); + let sig = Wots32::sign(&wots_sec[sec_index], &assn); sigs.1.0[i] = sig; } else if index < NUM_PUBS + NUM_U256 { let i = index - NUM_PUBS; let assn = scramble; - let sig = Wots32::sign(&wots_sec[index], &assn); + let sig = Wots32::sign(&wots_sec[sec_index], &assn); sigs.1.1[i] = sig; } else if index < NUM_PUBS + NUM_U256 + NUM_HASH { let i = index - NUM_PUBS - NUM_U256; let assn = scramble2; - let sig = Wots16::sign(&wots_sec[index], &assn); + let sig = Wots16::sign(&wots_sec[sec_index], &assn); sigs.1.2[i] = sig; } } From beb32dcd781e41fc64de408ed0599fc3a93966e4 Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:16:06 +0800 Subject: [PATCH 6/8] increase nst fee rate --- node/src/utils.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/node/src/utils.rs b/node/src/utils.rs index b5758a87..5968d535 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -1658,6 +1658,10 @@ pub async fn get_fee_rate(client: &BTCClient) -> Result { } } +pub async fn get_nst_fee_rate(client: &BTCClient) -> Result { + Ok(get_fee_rate(client).await? * 3.0) +} + pub async fn broadcast_nonstandard_tx(btc_client: &BTCClient, tx: &Transaction) -> Result<()> { match broadcast_tx(btc_client, tx).await { Ok(_) => Ok(()), @@ -2224,7 +2228,7 @@ pub async fn build_sign_and_broadcast_non_standard_tx( ) -> Result { let fixed_inputs_num = tx.input.len(); let total_output_amount: Amount = tx.output.iter().map(|o| o.value).sum(); - let fee_rate = get_fee_rate(client).await?; + let fee_rate = get_nst_fee_rate(client).await?; let node_address = node_p2wsh_address(get_network(), &node_keypair.public_key().into()); let shortfall = Amount::from_sat(total_output_amount.to_sat().saturating_sub(total_input_amount.to_sat())); From e25926f455f064e61efad1bc437f42f64acda2fe Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:08:36 +0800 Subject: [PATCH 7/8] read network info from env --- crates/bitvm2-ga/src/keys.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/bitvm2-ga/src/keys.rs b/crates/bitvm2-ga/src/keys.rs index 246a3cf7..6aff5164 100644 --- a/crates/bitvm2-ga/src/keys.rs +++ b/crates/bitvm2-ga/src/keys.rs @@ -67,7 +67,20 @@ fn derive_secret(master_key: &Keypair, domain: &[u8]) -> String { fn derive_bip32_root(master_key: &Keypair) -> Xpriv { let seed = hkdf_expand(master_key, HKDF_SALT, BITVM_BIP32_ROOT_DOMAIN, 64); - Xpriv::new_master(Network::Bitcoin, &seed) + let network = std::env::var("BITCOIN_NETWORK").unwrap_or("testnet4".to_string()); + let network = match network.as_str() { + "bitcoin" => Network::Bitcoin, + "testnet4" => Network::Testnet4, + "signet" => Network::Signet, + "regtest" => Network::Regtest, + _ => { + tracing::warn!( + "Unknown BTC network: {network}, expect bitcoin, testnet4, signet or regtest, return testnet by default" + ); + Network::Testnet4 + } + }; + Xpriv::new_master(network, &seed) .expect("32-byte secp256k1 key with valid seed should derive xpriv") } From 14051be602948779f039e4361ac506c02aa54d2c Mon Sep 17 00:00:00 2001 From: KSlashh <48985735+KSlashh@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:05:12 +0800 Subject: [PATCH 8/8] fix: not overwrite corrupt envelope --- node/src/handle.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/node/src/handle.rs b/node/src/handle.rs index 693ec45d..de1f1860 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -47,6 +47,14 @@ fn committee_instance_keys_envelope_path(instance_id: Uuid) -> std::path::PathBu path } +fn is_io_not_found_error(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound) + }) +} + fn load_committee_instance_keypair( committee_master_key: &CommitteeMasterKey, instance_id: Uuid, @@ -69,8 +77,17 @@ fn load_or_create_committee_instance_keypair( match committee_master_key.load_instance_keypair(instance_id, &envelope_path) { Ok(keypair) => Ok(keypair), Err(load_err) => { + if !is_io_not_found_error(&load_err) { + return Err(load_err).with_context(|| { + format!( + "load committee instance keypair failed for {} at {} (refuse auto-overwrite for non-missing envelope)", + instance_id, + envelope_path.display() + ) + }); + } tracing::info!( - "committee instance key not found/invalid for {} at {}: {}, creating new envelope", + "committee instance key not found for {} at {}: {}, creating new envelope", instance_id, envelope_path.display(), load_err