From 7ccf181171ed0628209a91a421104dd845485446 Mon Sep 17 00:00:00 2001 From: Danial Mehrjerdi Date: Thu, 26 Jun 2025 11:32:54 +0200 Subject: [PATCH 1/2] Add signer trait --- src/api_client.rs | 20 ++++----- src/main.rs | 96 +++++++++++++------------------------------ src/signer.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 80 deletions(-) create mode 100644 src/signer.rs diff --git a/src/api_client.rs b/src/api_client.rs index 75a4a10..b7304a9 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -1,6 +1,6 @@ use { + crate::signer::Signer, reqwest::{Client, Url}, - secp256k1::{Message, Secp256k1, SecretKey}, serde::Serialize, std::{sync::Arc, time::Duration}, wormhole_sdk::vaa::Body, @@ -41,16 +41,11 @@ where } impl Observation

{ - pub fn try_new(body: Body

, secret_key: SecretKey) -> Result { + pub fn try_new(body: Body

, signer: impl Signer) -> Result { let digest = body.digest()?; - let signature = Secp256k1::new() - .sign_ecdsa_recoverable(&Message::from_digest(digest.secp256k_hash), &secret_key); - let (recovery_id, signature_bytes) = signature.serialize_compact(); - let recovery_id: i32 = recovery_id.into(); - let mut signature = [0u8; 65]; - signature[..64].copy_from_slice(&signature_bytes); - signature[64] = recovery_id as u8; - + let signature = signer + .sign(digest.secp256k_hash) + .map_err(|e| anyhow::anyhow!("Failed to sign observation: {}", e))?; Ok(Self { version: 1, signature, @@ -116,7 +111,7 @@ impl ApiClient { mod tests { use secp256k1::{ ecdsa::{RecoverableSignature, RecoveryId}, - PublicKey, + Message, PublicKey, Secp256k1, SecretKey, }; use serde_json::Value; use serde_wormhole::RawMessage; @@ -127,6 +122,7 @@ mod tests { #[test] fn test_new_signed_observation() { let secret_key = SecretKey::from_byte_array(&[1u8; 32]).expect("Invalid secret key length"); + let signer = crate::signer::Local { secret_key }; let body = Body { timestamp: 1234567890, nonce: 42, @@ -137,7 +133,7 @@ mod tests { payload: vec![1, 2, 3, 4, 5], }; let observation = - Observation::try_new(body.clone(), secret_key).expect("Failed to create observation"); + Observation::try_new(body.clone(), signer).expect("Failed to create observation"); assert_eq!(observation.version, 1); assert_eq!(observation.body, body); diff --git a/src/main.rs b/src/main.rs index 45eacff..86e980e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ use { - crate::config::Command, + crate::{ + config::Command, + signer::{GuardianKey, Signer, GUARDIAN_KEY_ARMORED_BLOCK, STANDARD_ARMOR_LINE_HEADER}, + }, api_client::{ApiClient, Observation}, borsh::BorshDeserialize, clap::Parser, posted_message::PostedMessageUnreliableData, prost::Message, - secp256k1::{rand::rngs::OsRng, PublicKey, Secp256k1, SecretKey}, - sequoia_openpgp::armor::{Kind, Reader, ReaderMode, Writer}, + secp256k1::{rand::rngs::OsRng, Secp256k1}, + sequoia_openpgp::armor::{Kind, Writer}, serde_wormhole::RawMessage, - sha3::{Digest, Keccak256}, solana_account_decoder::UiAccountEncoding, solana_client::{ nonblocking::pubsub_client::PubsubClient, @@ -20,7 +22,7 @@ use { solana_sdk::pubkey::Pubkey, std::{ fs, - io::{Cursor, IsTerminal, Read, Write}, + io::{IsTerminal, Write}, str::FromStr, time::Duration, }, @@ -32,10 +34,11 @@ use { mod api_client; mod config; mod posted_message; +mod signer; -struct RunListenerInput { +struct RunListenerInput { ws_url: String, - secret_key: SecretKey, + signer: T, wormhole_pid: Pubkey, accumulator_address: Pubkey, api_client: ApiClient, @@ -112,7 +115,9 @@ fn message_data_to_body(unreliable_data: &PostedMessageUnreliableData) -> Body<& } } -async fn run_listener(input: RunListenerInput) -> Result<(), PubsubClientError> { +async fn run_listener( + input: RunListenerInput, +) -> Result<(), PubsubClientError> { let client = PubsubClient::new(input.ws_url.as_str()).await?; let (mut stream, unsubscribe) = client .program_subscribe( @@ -143,10 +148,10 @@ async fn run_listener(input: RunListenerInput) -> Result<(), PubsubClientError> }; tokio::spawn({ - let api_client = input.api_client.clone(); + let (api_client, signer) = (input.api_client.clone(), input.signer.clone()); async move { let body = message_data_to_body(&unreliable_data); - match Observation::try_new(body.clone(), input.secret_key) { + match Observation::try_new(body.clone(), signer.clone()) { Ok(observation) => { if let Err(e) = api_client.post_observation(observation).await { tracing::error!(error = ?e, "Failed to post observation"); @@ -167,60 +172,8 @@ async fn run_listener(input: RunListenerInput) -> Result<(), PubsubClientError> )) } -#[derive(Clone, PartialEq, Message)] -pub struct GuardianKey { - #[prost(bytes = "vec", tag = "1")] - pub data: Vec, - #[prost(bool, tag = "2")] - pub unsafe_deterministic_key: bool, -} - -const GUARDIAN_KEY_ARMORED_BLOCK: &str = "WORMHOLE GUARDIAN PRIVATE KEY"; -const STANDARD_ARMOR_LINE_HEADER: &str = "PGP PRIVATE KEY BLOCK"; - -fn parse_and_verify_proto_guardian_key(content: String, mode: crate::config::Mode) -> GuardianKey { - let content = content.replace(GUARDIAN_KEY_ARMORED_BLOCK, STANDARD_ARMOR_LINE_HEADER); - let cursor = Cursor::new(content); - let mut armor_reader = Reader::from_reader(cursor, ReaderMode::Tolerant(Some(Kind::SecretKey))); - - let mut buf = Vec::new(); - armor_reader - .read_to_end(&mut buf) - .expect("Failed to read armored content"); - - let guardian_key = - GuardianKey::decode(&mut buf.as_slice()).expect("Failed to decode GuardianKey"); - - if let crate::config::Mode::Production = mode { - if guardian_key.unsafe_deterministic_key { - panic!("Unsafe deterministic key is not allowed in production mode"); - } - } - - guardian_key -} - -fn load_secret_key(run_options: config::RunOptions) -> SecretKey { - let content = fs::read_to_string(run_options.secret_key_path).expect("Failed to read file"); - let guardian_key = parse_and_verify_proto_guardian_key(content, run_options.mode); - SecretKey::from_slice(&guardian_key.data).expect("Failed to create SecretKey from bytes") -} - -fn get_public_key(secret_key: &SecretKey) -> (PublicKey, [u8; 20]) { - let secp = Secp256k1::new(); - let public_key = secret_key.public_key(&secp); - let pubkey_uncompressed = public_key.serialize_uncompressed(); - let pubkey_hash: [u8; 32] = Keccak256::new_with_prefix(&pubkey_uncompressed[1..]) - .finalize() - .into(); - let pubkey_evm: [u8; 20] = pubkey_hash[pubkey_hash.len() - 20..] - .try_into() - .expect("Invalid address length"); - (public_key, pubkey_evm) -} - async fn run(run_options: config::RunOptions) { - let secret_key = load_secret_key(run_options.clone()); + let signer = signer::Local::try_new(run_options.clone()).expect("Failed to create signer"); let client = PubsubClient::new(&run_options.pythnet_url) .await .expect("Invalid WebSocket URL"); @@ -232,7 +185,7 @@ async fn run(run_options: config::RunOptions) { let api_client = ApiClient::try_new(run_options.server_url, None).expect("Failed to create API client"); - let (pubkey, pubkey_evm) = get_public_key(&secret_key); + let (pubkey, pubkey_evm) = signer.get_public_key().expect("Failed to get public key"); let evm_encded_public_key = format!("0x{}", hex::encode(pubkey_evm)); tracing::info!( public_key = ?pubkey, @@ -243,7 +196,7 @@ async fn run(run_options: config::RunOptions) { loop { if let Err(e) = run_listener(RunListenerInput { ws_url: run_options.pythnet_url.clone(), - secret_key, + signer: signer.clone(), wormhole_pid, accumulator_address, api_client: api_client.clone(), @@ -285,7 +238,8 @@ async fn main() { // Generate keypair (secret + public key) let (secret_key, _) = secp.generate_keypair(&mut rng); - let (pubkey, pubkey_evm) = get_public_key(&secret_key); + let signer = signer::Local { secret_key }; + let (pubkey, pubkey_evm) = signer.get_public_key().expect("Failed to get public key"); let guardian_key = GuardianKey { data: secret_key.secret_bytes().to_vec(), @@ -322,6 +276,7 @@ mod tests { use base64::Engine; use borsh::BorshSerialize; + use secp256k1::SecretKey; use solana_account_decoder::{UiAccount, UiAccountData}; use crate::posted_message::MessageData; @@ -516,16 +471,21 @@ mod tests { -----END WORMHOLE GUARDIAN PRIVATE KEY----- " .to_string(); - let guardian_key = parse_and_verify_proto_guardian_key(content, config::Mode::Production); + let guardian_key = crate::signer::Local::parse_and_verify_proto_guardian_key( + content, + config::Mode::Production, + ) + .expect("Failed to parse and verify guardian key"); assert!(!guardian_key.unsafe_deterministic_key); let secret_key = SecretKey::from_slice(&guardian_key.data) .expect("Failed to create SecretKey from bytes"); + let signer = signer::Local { secret_key }; assert_eq!( hex::encode(secret_key.secret_bytes()), "f2f3127bff540c8441f99763f586858ef340c9962ad62b6181cd77203e81808f", ); assert_eq!( - hex::encode(get_public_key(&secret_key).1), + hex::encode(signer.get_public_key().expect("Failed to get public key").1), "30e41be3f10d3ac813f91e49e189bbb948d030be", ); } diff --git a/src/signer.rs b/src/signer.rs new file mode 100644 index 0000000..8ad5149 --- /dev/null +++ b/src/signer.rs @@ -0,0 +1,102 @@ +use std::{ + fs, + io::{Cursor, Read}, +}; + +use prost::Message as ProstMessage; +use secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; +use sequoia_openpgp::armor::{Kind, Reader, ReaderMode}; +use sha3::{Digest, Keccak256}; + +use crate::config::RunOptions; + +pub trait Signer: Send + Sync + Sized + Clone { + fn try_new(run_options: RunOptions) -> anyhow::Result; + fn sign(&self, data: [u8; 32]) -> anyhow::Result<[u8; 65]>; + fn get_public_key(&self) -> anyhow::Result<(PublicKey, [u8; 20])>; +} + +#[derive(Clone, Debug)] +pub struct Local { + pub secret_key: SecretKey, +} + +#[derive(Clone, PartialEq, ProstMessage)] +pub struct GuardianKey { + #[prost(bytes = "vec", tag = "1")] + pub data: Vec, + #[prost(bool, tag = "2")] + pub unsafe_deterministic_key: bool, +} + +pub const GUARDIAN_KEY_ARMORED_BLOCK: &str = "WORMHOLE GUARDIAN PRIVATE KEY"; +pub const STANDARD_ARMOR_LINE_HEADER: &str = "PGP PRIVATE KEY BLOCK"; + +impl Local { + pub fn parse_and_verify_proto_guardian_key( + content: String, + mode: crate::config::Mode, + ) -> anyhow::Result { + let content = content.replace(GUARDIAN_KEY_ARMORED_BLOCK, STANDARD_ARMOR_LINE_HEADER); + let cursor = Cursor::new(content); + let mut armor_reader = + Reader::from_reader(cursor, ReaderMode::Tolerant(Some(Kind::SecretKey))); + + let mut buf = Vec::new(); + armor_reader + .read_to_end(&mut buf) + .map_err(|e| anyhow::anyhow!("Failed to read armored key: {}", e))?; + + let guardian_key = GuardianKey::decode(&mut buf.as_slice()) + .map_err(|e| anyhow::anyhow!("Failed to decode GuardianKey: {}", e))?; + + if let crate::config::Mode::Production = mode { + if guardian_key.unsafe_deterministic_key { + return Err(anyhow::anyhow!( + "Unsafe deterministic key is not allowed in production mode" + )); + } + } + + Ok(guardian_key) + } +} + +impl Signer for Local { + fn try_new(run_options: RunOptions) -> anyhow::Result { + let content = fs::read_to_string(run_options.secret_key_path) + .map_err(|e| anyhow::anyhow!("Failed to read secret key file: {}", e))?; + let guardian_key = Self::parse_and_verify_proto_guardian_key(content, run_options.mode)?; + Ok(Local { + secret_key: SecretKey::from_slice(&guardian_key.data) + .map_err(|e| anyhow::anyhow!("Failed to create SecretKey: {}", e))?, + }) + } + + fn sign(&self, data: [u8; 32]) -> anyhow::Result<[u8; 65]> { + let signature = + Secp256k1::new().sign_ecdsa_recoverable(&Message::from_digest(data), &self.secret_key); + let (recovery_id, signature_bytes) = signature.serialize_compact(); + let recovery_id: i32 = recovery_id.into(); + let mut signature = [0u8; 65]; + signature[..64].copy_from_slice(&signature_bytes); + signature[64] = recovery_id as u8; + Ok(signature) + } + + fn get_public_key(&self) -> anyhow::Result<(PublicKey, [u8; 20])> { + let secp = Secp256k1::new(); + let public_key = self.secret_key.public_key(&secp); + let pubkey_uncompressed = public_key.serialize_uncompressed(); + let pubkey_hash: [u8; 32] = Keccak256::new_with_prefix(&pubkey_uncompressed[1..]) + .finalize() + .into(); + let pubkey_evm: [u8; 20] = + pubkey_hash[pubkey_hash.len() - 20..] + .try_into() + .map_err(|e| { + anyhow::anyhow!("Failed to convert public key hash to EVM format: {}", e) + })?; + Ok((public_key, pubkey_evm)) + } +} From 820c7633e4ffecaaaf391d9c428576ed56dedc41 Mon Sep 17 00:00:00 2001 From: Danial Mehrjerdi Date: Thu, 26 Jun 2025 16:26:55 +0200 Subject: [PATCH 2/2] Address comments --- src/api_client.rs | 2 +- src/main.rs | 8 ++++---- src/signer.rs | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/api_client.rs b/src/api_client.rs index b7304a9..c47b231 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -122,7 +122,7 @@ mod tests { #[test] fn test_new_signed_observation() { let secret_key = SecretKey::from_byte_array(&[1u8; 32]).expect("Invalid secret key length"); - let signer = crate::signer::Local { secret_key }; + let signer = crate::signer::FileSigner { secret_key }; let body = Body { timestamp: 1234567890, nonce: 42, diff --git a/src/main.rs b/src/main.rs index 86e980e..8aa231b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,7 +173,7 @@ async fn run_listener( } async fn run(run_options: config::RunOptions) { - let signer = signer::Local::try_new(run_options.clone()).expect("Failed to create signer"); + let signer = signer::FileSigner::try_new(run_options.clone()).expect("Failed to create signer"); let client = PubsubClient::new(&run_options.pythnet_url) .await .expect("Invalid WebSocket URL"); @@ -238,7 +238,7 @@ async fn main() { // Generate keypair (secret + public key) let (secret_key, _) = secp.generate_keypair(&mut rng); - let signer = signer::Local { secret_key }; + let signer = signer::FileSigner { secret_key }; let (pubkey, pubkey_evm) = signer.get_public_key().expect("Failed to get public key"); let guardian_key = GuardianKey { @@ -471,7 +471,7 @@ mod tests { -----END WORMHOLE GUARDIAN PRIVATE KEY----- " .to_string(); - let guardian_key = crate::signer::Local::parse_and_verify_proto_guardian_key( + let guardian_key = crate::signer::FileSigner::parse_and_verify_proto_guardian_key( content, config::Mode::Production, ) @@ -479,7 +479,7 @@ mod tests { assert!(!guardian_key.unsafe_deterministic_key); let secret_key = SecretKey::from_slice(&guardian_key.data) .expect("Failed to create SecretKey from bytes"); - let signer = signer::Local { secret_key }; + let signer = signer::FileSigner { secret_key }; assert_eq!( hex::encode(secret_key.secret_bytes()), "f2f3127bff540c8441f99763f586858ef340c9962ad62b6181cd77203e81808f", diff --git a/src/signer.rs b/src/signer.rs index 8ad5149..7926072 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -17,7 +17,7 @@ pub trait Signer: Send + Sync + Sized + Clone { } #[derive(Clone, Debug)] -pub struct Local { +pub struct FileSigner { pub secret_key: SecretKey, } @@ -32,7 +32,7 @@ pub struct GuardianKey { pub const GUARDIAN_KEY_ARMORED_BLOCK: &str = "WORMHOLE GUARDIAN PRIVATE KEY"; pub const STANDARD_ARMOR_LINE_HEADER: &str = "PGP PRIVATE KEY BLOCK"; -impl Local { +impl FileSigner { pub fn parse_and_verify_proto_guardian_key( content: String, mode: crate::config::Mode, @@ -62,12 +62,12 @@ impl Local { } } -impl Signer for Local { +impl Signer for FileSigner { fn try_new(run_options: RunOptions) -> anyhow::Result { let content = fs::read_to_string(run_options.secret_key_path) .map_err(|e| anyhow::anyhow!("Failed to read secret key file: {}", e))?; let guardian_key = Self::parse_and_verify_proto_guardian_key(content, run_options.mode)?; - Ok(Local { + Ok(FileSigner { secret_key: SecretKey::from_slice(&guardian_key.data) .map_err(|e| anyhow::anyhow!("Failed to create SecretKey: {}", e))?, })