diff --git a/node/src/bin/space-cli.rs b/node/src/bin/space-cli.rs index 8e42835..2f4e99a 100644 --- a/node/src/bin/space-cli.rs +++ b/node/src/bin/space-cli.rs @@ -22,6 +22,7 @@ use spaced::{ store::Sha256, wallets::AddressKind, }; +use spaced::rpc::SignedMessage; use wallet::bitcoin::secp256k1::schnorr::Signature; use wallet::export::WalletExport; use wallet::Listing; @@ -193,6 +194,27 @@ enum Commands { #[arg(long, short)] fee_rate: Option, }, + /// Sign a message using the owner address of the specified space + #[command(name = "signmessage")] + SignMessage { + /// The space to use + space: String, + /// The message to sign + message: String, + }, + /// Verify a message using the owner address of the specified space + #[command(name = "verifymessage")] + VerifyMessage { + /// The space to verify + space: String, + + /// The message to verify + message: String, + + /// The signature to verify + #[arg(long)] + signature: String, + }, /// List a space you own for sale #[command(name = "sell")] Sell { @@ -700,6 +722,25 @@ async fn handle_commands( .verify_listing(listing).await?; println!("{}", serde_json::to_string_pretty(&result).expect("result")); } + Commands::SignMessage { mut space, message } => { + space = normalize_space(&space); + let result = cli.client + .wallet_sign_message(&cli.wallet, &space, protocol::Bytes::new(message.as_bytes().to_vec())).await?; + println!("{}", result.signature); + } + Commands::VerifyMessage { mut space, message, signature } => { + space = normalize_space(&space); + let raw = hex::decode(signature) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?; + let signature = Signature::from_slice(raw.as_slice()) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?; + let result = cli.client.verify_message(SignedMessage { + space, + message: protocol::Bytes::new(message.as_bytes().to_vec()), + signature, + }).await?; + println!("{}", serde_json::to_string_pretty(&result).expect("result")); + } } Ok(()) diff --git a/node/src/rpc.rs b/node/src/rpc.rs index e0a1fd3..78d76ee 100644 --- a/node/src/rpc.rs +++ b/node/src/rpc.rs @@ -15,26 +15,18 @@ use bdk::{ }; use jsonrpsee::{core::async_trait, proc_macros::rpc, server::Server, types::ErrorObjectOwned}; use log::info; -use protocol::{ - bitcoin, - bitcoin::{ - bip32::Xpriv, - Network::{Regtest, Testnet}, - OutPoint, - }, - constants::ChainAnchor, - hasher::{BaseHash, KeyHasher, SpaceKey}, - prepare::DataSource, - slabel::SLabel, - validate::TxChangeSet, - FullSpaceOut, SpaceOut, -}; +use protocol::{bitcoin, bitcoin::{ + bip32::Xpriv, + Network::{Regtest, Testnet}, + OutPoint, +}, constants::ChainAnchor, hasher::{BaseHash, KeyHasher, SpaceKey}, prepare::DataSource, slabel::SLabel, validate::TxChangeSet, Bytes, FullSpaceOut, SpaceOut}; use serde::{Deserialize, Serialize}; use tokio::{ select, sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; +use protocol::bitcoin::secp256k1; use wallet::{bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, Balance, DoubleUtxo, Listing, SpacesWallet, WalletConfig, WalletDescriptors, WalletInfo, WalletOutput}; use crate::{ @@ -58,6 +50,13 @@ pub struct ServerInfo { pub tip: ChainAnchor, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedMessage { + pub space: String, + pub message: protocol::Bytes, + pub signature: secp256k1::schnorr::Signature, +} + pub enum ChainStateCommand { CheckPackage { txs: Vec, @@ -99,6 +98,10 @@ pub enum ChainStateCommand { listing: Listing, resp: Responder>, }, + VerifyMessage { + msg: SignedMessage, + resp: Responder>, + }, } #[derive(Clone)] @@ -153,6 +156,12 @@ pub trait Rpc { #[method(name = "walletimport")] async fn wallet_import(&self, wallet: WalletExport) -> Result<(), ErrorObjectOwned>; + #[method(name = "verifymessage")] + async fn verify_message(&self, msg: SignedMessage) -> Result<(), ErrorObjectOwned>; + + #[method(name = "walletsignmessage")] + async fn wallet_sign_message(&self, wallet: &str, space: &str, msg: protocol::Bytes) -> Result; + #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) -> Result; @@ -797,6 +806,14 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_sign_message(&self, wallet: &str, space: &str, msg: Bytes) -> Result { + self.wallet(&wallet) + .await? + .send_sign_message(space, msg) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned> { self.store .verify_listing(listing) @@ -804,6 +821,13 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn verify_message(&self, msg: SignedMessage) -> Result<(), ErrorObjectOwned> { + self.store + .verify_message(msg) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_list_transactions( &self, wallet: &str, @@ -1006,6 +1030,11 @@ impl AsyncChainState { ChainStateCommand::VerifyListing { listing, resp } => { _ = resp.send(SpacesWallet::verify_listing::(chain_state, &listing).map(|_| ())); } + ChainStateCommand::VerifyMessage { msg, resp } => { + _ = resp.send(SpacesWallet::verify_message::( + chain_state, &msg.space, msg.message.as_slice(), &msg.signature + ).map(|_| ())); + } } } @@ -1047,6 +1076,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn verify_message(&self, msg: SignedMessage) -> anyhow::Result<()> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::VerifyMessage { msg, resp }) + .await?; + resp_rx.await? + } + pub async fn get_rollout(&self, target: usize) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender diff --git a/node/src/wallets.rs b/node/src/wallets.rs index bdfa70c..67a9839 100644 --- a/node/src/wallets.rs +++ b/node/src/wallets.rs @@ -22,6 +22,7 @@ use wallet::{address::SpaceAddress, bdk_wallet::{ use crate::{checker::TxChecker, config::ExtendedNetwork, node::BlockSource, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, }, std_wait, store::{ChainState, LiveSnapshot, Sha256}}; +use crate::rpc::SignedMessage; const MEMPOOL_CHECK_INTERVAL: Duration = Duration::from_millis( if cfg!(debug_assertions) { 500 } else { 10_000 } @@ -111,6 +112,11 @@ pub enum WalletCommand { resp: crate::rpc::Responder>, }, UnloadWallet, + SignMessage { + space: String, + msg: protocol::Bytes, + resp: crate::rpc::Responder>, + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -375,6 +381,20 @@ impl RpcWallet { WalletCommand::Sell { space, price, resp } => { _ = resp.send(wallet.sell::(state, &space, Amount::from_sat(price))); } + WalletCommand::SignMessage { space, msg, resp } => { + match wallet.sign_message::(state, &space, msg.as_slice()) { + Ok(signature) => { + _ = resp.send(Ok(SignedMessage { + space, + message: msg, + signature, + })); + } + Err(err) => { + _ = resp.send(Err(err)); + } + } + } } Ok(()) } @@ -1172,6 +1192,22 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_sign_message( + &self, + space: &str, + msg: protocol::Bytes + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::SignMessage { + space: space.to_string(), + msg, + resp, + }) + .await?; + resp_rx.await? + } + pub async fn send_list_transactions( &self, count: usize, diff --git a/node/tests/integration_tests.rs b/node/tests/integration_tests.rs index d7266c0..2918e7f 100644 --- a/node/tests/integration_tests.rs +++ b/node/tests/integration_tests.rs @@ -1,5 +1,5 @@ use std::{path::PathBuf, str::FromStr}; -use protocol::{bitcoin::{Amount, FeeRate}, constants::RENEWAL_INTERVAL, script::SpaceScript, Covenant}; +use protocol::{bitcoin::{Amount, FeeRate}, constants::RENEWAL_INTERVAL, script::SpaceScript, Bytes, Covenant}; use spaced::{ rpc::{ BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, @@ -1025,6 +1025,34 @@ async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { Ok(()) } +async fn it_should_allow_sign_verify_messages(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(BOB).await.expect("synced"); + + let alice_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); + let space = alice_spaces.owned.first().expect("bob should have at least 1 space"); + + let space_name = space.spaceout.space.as_ref().unwrap().name.to_string(); + + let msg = Bytes::new(b"hello world".to_vec()); + let signed = rig.spaced.client.wallet_sign_message(BOB, &space_name, msg.clone()).await.expect("sign"); + + println!("signed\n{}", serde_json::to_string_pretty(&signed).unwrap()); + assert_eq!(signed.space, space_name, "bad signer"); + assert_eq!(signed.message.as_slice(), msg.as_slice(), "msg content must match"); + + rig.spaced.client.verify_message(signed.clone()).await.expect("verify"); + + let mut bad_signer = signed.clone(); + bad_signer.space = "@nothanks".to_string(); + rig.spaced.client.verify_message(bad_signer).await.expect_err("bad signer"); + + let mut bad_msg = signed.clone(); + bad_msg.message = Bytes::new(b"hello world 2".to_vec()); + rig.spaced.client.verify_message(bad_msg).await.expect_err("bad msg"); + + Ok(()) +} + async fn it_should_handle_reorgs(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await.expect("synced"); const NAME: &str = "hello_world"; @@ -1066,10 +1094,11 @@ async fn run_auction_tests() -> anyhow::Result<()> { .expect("should not allow register/transfer multiple times"); it_can_batch_txs(&rig).await.expect("bump fee"); it_can_use_reserved_op_codes(&rig).await.expect("should use reserved opcodes"); - it_should_allow_buy_sell(&rig).await.expect("should use reserved opcodes"); + it_should_allow_buy_sell(&rig).await.expect("should allow buy sell"); + it_should_allow_sign_verify_messages(&rig).await.expect("should sign verify"); // keep reorgs last as it can drop some txs from mempool and mess up wallet state - it_should_handle_reorgs(&rig).await.expect("should make wallet"); + it_should_handle_reorgs(&rig).await.expect("should handle reorgs wallet"); Ok(()) } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 18d6c12..bf93124 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -11,8 +11,13 @@ use bdk_wallet::{chain, chain::BlockId, coin_selection::{CoinSelectionAlgorithm, use bdk_wallet::chain::{ChainPosition, Indexer}; use bdk_wallet::chain::local_chain::{CannotConnectError, LocalChain}; use bdk_wallet::chain::tx_graph::CalculateFeeError; +use bdk_wallet::keys::DescriptorSecretKey; use bincode::config; -use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxIn, TxOut, Txid, Weight, Witness}; +use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxIn, TxOut, Txid, VarInt, Weight, Witness}; +use bitcoin::bip32::{ChildNumber}; +use bitcoin::consensus::Encodable; +use bitcoin::hashes::{sha256d, Hash, HashEngine}; +use bitcoin::key::{TapTweak, TweakedKeypair}; use bitcoin::transaction::Version; use secp256k1::{schnorr, Message}; use secp256k1::schnorr::Signature; @@ -46,6 +51,8 @@ pub mod export; mod rusqlite_impl; pub mod tx_event; +pub const SPACES_SIGNED_MSG_PREFIX: &[u8] = b"\x17Spaces Signed Message:\n"; + pub struct SpacesWallet { pub config: WalletConfig, internal: PersistedWallet, @@ -339,6 +346,61 @@ impl SpacesWallet { TxEvent::get_latest_events(&db_tx).context("could not read latest events") } + pub fn sign_message(&mut self, src: &mut impl DataSource, space: &str, msg: impl AsRef<[u8]>) -> anyhow::Result { + let label = SLabel::from_str(space)?; + let space_key = SpaceKey::from(H::hash(label.as_ref())); + let outpoint = match src.get_space_outpoint(&space_key)? { + None => return Err(anyhow::anyhow!("Space not found")), + Some(outpoint) => outpoint, + }; + let utxo = match self.get_utxo(outpoint) { + None => return Err(anyhow::anyhow!("Space not owned by wallet")), + Some(utxo) => utxo, + }; + + let keypair = self.get_taproot_keypair(utxo.keychain, utxo.derivation_index) + .context("Could not derive taproot keypair to sign message")?; + + let msg_hash = signed_msg_hash(msg); + let msg_to_sign = secp256k1::Message::from_digest(msg_hash.to_byte_array()); + let ctx = secp256k1::Secp256k1::new(); + Ok(ctx.sign_schnorr(&msg_to_sign, &keypair.to_inner())) + } + + pub fn verify_message( + src: &mut impl DataSource, + space: &str, + msg: impl AsRef<[u8]>, + signature: &Signature + ) -> anyhow::Result<()> { + let label = SLabel::from_str(space)?; + let space_key = SpaceKey::from(H::hash(label.as_ref())); + let outpoint = match src.get_space_outpoint(&space_key)? { + None => return Err(anyhow::anyhow!("Space not found")), + Some(outpoint) => outpoint, + }; + let spaceout = match src.get_spaceout(&outpoint)? { + None => return Err(anyhow::anyhow!("Space not found")), + Some(spaceout) => spaceout, + }; + if !spaceout.script_pubkey.is_witness_program() { + return Err(anyhow::anyhow!("Cannot verify non-taproot spaces")) + } + + let script_bytes = spaceout.script_pubkey.as_bytes(); + if script_bytes.len() != secp256k1::constants::SCHNORR_PUBLIC_KEY_SIZE + 2 { + return Err(anyhow::anyhow!("Expected a schnorr public key")); + } + + let pubkey = XOnlyPublicKey::from_slice(&script_bytes[2..])?; + let ctx = secp256k1::Secp256k1::new(); + let msg_hash = signed_msg_hash(msg); + let msg_to_sign = Message::from_digest(msg_hash.to_byte_array()); + + ctx.verify_schnorr(signature, &msg_to_sign, &pubkey)?; + Ok(()) + } + pub fn list_unspent_with_details(&mut self, store: &mut impl DataSource) -> anyhow::Result> { let mut wallet_outputs = Vec::new(); for output in self.internal.list_unspent() { @@ -581,7 +643,7 @@ impl SpacesWallet { } pub fn buy(&mut self, src: &mut impl DataSource, listing: &Listing, fee_rate: FeeRate) -> anyhow::Result { - let (seller,spaceout) = Self::verify_listing::(src, &listing)?; + let (seller, spaceout) = Self::verify_listing::(src, &listing)?; let mut witness = Witness::new(); witness.push( @@ -646,7 +708,7 @@ impl SpacesWallet { return Err(anyhow!("No associated space")); } if !matches!(spaceout.space.as_ref().unwrap().covenant, Covenant::Transfer { ..}) { - return Err(anyhow::anyhow!("Space not registered")) + return Err(anyhow::anyhow!("Space not registered")); } let recipient = Self::verify_listing_signature(&listing, outpoint, TxOut { @@ -714,7 +776,7 @@ impl SpacesWallet { Some(spaceout) => spaceout, }; if !matches!(spaceout.space.as_ref().unwrap().covenant, Covenant::Transfer { ..}) { - return Err(anyhow::anyhow!("Space not registered")) + return Err(anyhow::anyhow!("Space not registered")); } let utxo = match self.internal.get_utxo(space_outpoint) { @@ -864,6 +926,26 @@ impl SpacesWallet { } } + pub fn get_taproot_keypair(&self, keychain: KeychainKind, derivation_index: u32) -> anyhow::Result { + let secret = match self.internal.get_signers(keychain) + .signers() + .iter() + .filter_map(|s| s.descriptor_secret_key()).next() { + None => return Err(anyhow::anyhow!("No secret key found in signer")), + Some(secret) => secret, + }; + let descriptor_x_key = match secret { + DescriptorSecretKey::XPrv(xprv) => xprv, + _ => return Err(anyhow::anyhow!("No xprv found")), + }; + let full_path = descriptor_x_key.derivation_path + .child(ChildNumber::Normal { index: derivation_index }); + let ctx = secp256k1::Secp256k1::new(); + let xprv = descriptor_x_key.xkey.derive_priv(&ctx, &full_path)?; + let keypair = UntweakedKeypair::from_secret_key(&ctx, &xprv.private_key); + Ok(keypair.tap_tweak(&ctx, None)) + } + pub fn sign( &mut self, mut psbt: Psbt, @@ -1174,3 +1256,12 @@ impl<'de> Deserialize<'de> for SpaceScriptSigningInfo { deserializer.deserialize_seq(OpenSigningInfoVisitor) } } + +pub fn signed_msg_hash(msg: impl AsRef<[u8]>) -> sha256d::Hash { + let msg_bytes = msg.as_ref(); + let mut engine = sha256d::Hash::engine(); + engine.input(SPACES_SIGNED_MSG_PREFIX); + VarInt::from(msg_bytes.len()).consensus_encode(&mut engine).expect("varint serialization"); + engine.input(msg_bytes); + sha256d::Hash::from_engine(engine) +}