From 3eb14dd766c4e771770333836ddaa982db3a3501 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Mon, 5 Feb 2024 17:35:05 +0100 Subject: [PATCH] Add wallet commands to sign and verify arbitrary messages --- .../signature/inputsig/arbitrary_message.rs | 4 + .../test_framework/wallet_cli_controller.py | 12 ++ test/functional/test_runner.py | 1 + test/functional/wallet_sign_message.py | 102 ++++++++++++++++ test/functional/wallet_tx_compose.py | 2 +- wallet/src/account/mod.rs | 22 ++++ wallet/src/wallet/mod.rs | 18 +++ wallet/wallet-cli-lib/src/commands/mod.rs | 111 ++++++++++++++++++ .../src/synced_controller.rs | 11 ++ wallet/wallet-rpc-lib/src/rpc/mod.rs | 44 +++++++ wallet/wallet-rpc-lib/src/rpc/types.rs | 4 + 11 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 test/functional/wallet_sign_message.py diff --git a/common/src/chain/transaction/signature/inputsig/arbitrary_message.rs b/common/src/chain/transaction/signature/inputsig/arbitrary_message.rs index 476cb2aed1..ec5597da0c 100644 --- a/common/src/chain/transaction/signature/inputsig/arbitrary_message.rs +++ b/common/src/chain/transaction/signature/inputsig/arbitrary_message.rs @@ -70,6 +70,10 @@ impl SignedArbitraryMessage { Self { raw_signature } } + pub fn to_hex(self) -> String { + hex::encode(self.raw_signature) + } + pub fn verify_signature( &self, chain_config: &ChainConfig, diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 2b02038dc7..f4d1dde516 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -294,6 +294,18 @@ async def decommission_stake_pool_request(self, pool_id: str) -> str: async def sign_raw_transaction(self, transaction: str) -> str: return await self._write_command(f"account-sign-raw-transaction {transaction}\n") + async def sign_challenge_plain(self, message: str, address: str) -> str: + return await self._write_command(f'account-sign-challenge-plain "{message}" {address}\n') + + async def sign_challenge_hex(self, message: str, address: str) -> str: + return await self._write_command(f'account-sign-challenge-hex "{message}" {address}\n') + + async def verify_challenge_plain(self, message: str, signature: str, address: str) -> str: + return await self._write_command(f'verify-challenge-plain "{message}" {signature} {address}\n') + + async def verify_challenge_hex(self, message: str, signature: str, address: str) -> str: + return await self._write_command(f'verify-challenge-hex "{message}" {signature} {address}\n') + async def submit_transaction(self, transaction: str) -> str: return await self._write_command(f"node-submit-transaction {transaction}\n") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 3b142dc4a3..19e10e815b 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -130,6 +130,7 @@ class UnicodeOnWindowsError(ValueError): 'feature_db_reinit.py', 'feature_lmdb_backend_test.py', 'wallet_conflict.py', + 'wallet_sign_message.py', 'wallet_tx_compose.py', 'wallet_data_deposit.py', 'wallet_submit_tx.py', diff --git a/test/functional/wallet_sign_message.py b/test/functional/wallet_sign_message.py new file mode 100644 index 0000000000..402618c282 --- /dev/null +++ b/test/functional/wallet_sign_message.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Wallet sign arbitrary message test + +Check that: +* We can create a new cold wallet, +* generate an address +* sign a random message +* open a different wallet and verify the signature +""" + +from random import choice, randint +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_in +from test_framework.wallet_cli_controller import WalletCliController + +import asyncio +import sys +import string + +class WalletSignMessage(BitcoinTestFramework): + + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [[ + "--blockprod-min-peers-to-produce-blocks=0", + ]] + + def setup_network(self): + self.setup_nodes() + self.sync_all(self.nodes[0:1]) + + def run_test(self): + if 'win32' in sys.platform: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(self.async_test()) + + async def async_test(self): + node = self.nodes[0] + use_hex = choice([True, False]) + message = "".join([choice(string.digits + string.ascii_letters) for _ in range(randint(20, 40))]) + if use_hex: + message = message.encode().hex() + + async with WalletCliController(node, self.config, self.log, chain_config_args=["--chain-pos-netupgrades", "true", "--cold-wallet"]) as wallet: + # new cold wallet + await wallet.create_wallet("cold_wallet") + + destination = await wallet.new_address() + if use_hex: + output = await wallet.sign_challenge_hex(message, destination) + else: + output = await wallet.sign_challenge_plain(message, destination) + assert_in("The generated hex encoded signature is", output) + signature = output.split('\n')[1] + + await wallet.close_wallet() + + # new hot wallet + await wallet.create_wallet("another_cold_wallet") + + # try to sign the message with the new wallet should fail + if use_hex: + output = await wallet.sign_challenge_hex(message, destination) + else: + output = await wallet.sign_challenge_plain(message, destination) + assert_in("Destination does not belong to this wallet", output) + + if use_hex: + output = await wallet.verify_challenge_hex(message, signature, destination) + else: + output = await wallet.verify_challenge_plain(message, signature, destination) + assert_in("The provided signature is correct", output) + + # try to verify with wrong message + different_message = "".join([choice(string.digits + string.ascii_letters) for _ in range(randint(20, 40))]) + if use_hex: + different_message = different_message.encode().hex() + if use_hex: + output = await wallet.verify_challenge_hex(different_message, signature, destination) + else: + output = await wallet.verify_challenge_plain(different_message, signature, destination) + assert_in("Signature verification failed", output) + +if __name__ == '__main__': + WalletSignMessage().main() + diff --git a/test/functional/wallet_tx_compose.py b/test/functional/wallet_tx_compose.py index f6f0fd0d80..8d6fc29926 100644 --- a/test/functional/wallet_tx_compose.py +++ b/test/functional/wallet_tx_compose.py @@ -154,7 +154,7 @@ def make_output(pub_key_bytes): assert len(encoded_tx) < len(encoded_ptx) output = await wallet.sign_raw_transaction(encoded_tx) - assert_in("The transaction has been fully signed signed", output) + assert_in("The transaction has been fully signed and is ready to be broadcast to network.", output) signed_tx = output.split('\n')[2] assert_in("The transaction was submitted successfully", await wallet.submit_transaction(signed_tx)) diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 66822bcd55..a0f9d2896b 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -20,6 +20,7 @@ mod utxo_selector; use common::address::pubkeyhash::PublicKeyHash; use common::chain::block::timestamp::BlockTimestamp; +use common::chain::signature::inputsig::arbitrary_message::SignedArbitraryMessage; use common::chain::{AccountCommand, AccountOutPoint, AccountSpending, TransactionCreationError}; use common::primitives::id::WithId; use common::primitives::{Idable, H256}; @@ -1234,6 +1235,27 @@ impl Account { )) } + pub fn sign_challenge( + &self, + message: Vec, + destination: Destination, + db_tx: &impl WalletStorageReadUnlocked, + ) -> WalletResult { + let private_key = self + .key_chain + .get_private_key_for_destination(&destination, db_tx)? + .ok_or(WalletError::DestinationNotFromThisWallet)? + .private_key(); + + let sig = SignedArbitraryMessage::produce_uniparty_signature( + &private_key, + &destination, + &message, + )?; + + Ok(sig) + } + pub fn sign_raw_transaction( &self, tx: TransactionToSign, diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index a951ddac83..c2af75530d 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -32,6 +32,9 @@ use crate::{Account, SendRequest}; pub use bip39::{Language, Mnemonic}; use common::address::{Address, AddressError}; use common::chain::block::timestamp::BlockTimestamp; +use common::chain::signature::inputsig::arbitrary_message::{ + SignArbitraryMessageError, SignedArbitraryMessage, +}; use common::chain::signature::DestinationSigError; use common::chain::tokens::{ make_token_id, IsTokenUnfreezable, Metadata, RPCFungibleTokenInfo, TokenId, TokenIssuance, @@ -199,6 +202,10 @@ pub enum WalletError { FullySignedTransactionInDecommissionReq, #[error("Input cannot be signed")] InputCannotBeSigned, + #[error("Destination does not belong to this wallet")] + DestinationNotFromThisWallet, + #[error("Sign message error: {0}")] + SignMessageError(#[from] SignArbitraryMessageError), #[error("Input cannot be spent {0:?}")] InputCannotBeSpent(TxOutput), #[error("Failed to convert partially signed tx to signed")] @@ -1373,6 +1380,17 @@ impl Wallet { }) } + pub fn sign_challenge( + &mut self, + account_index: U31, + challenge: Vec, + destination: Destination, + ) -> WalletResult { + self.for_account_rw_unlocked(account_index, |account, db_tx, _| { + account.sign_challenge(challenge, destination, db_tx) + }) + } + pub fn get_pos_gen_block_data( &self, account_index: U31, diff --git a/wallet/wallet-cli-lib/src/commands/mod.rs b/wallet/wallet-cli-lib/src/commands/mod.rs index 70596af0a0..f7992844f9 100644 --- a/wallet/wallet-cli-lib/src/commands/mod.rs +++ b/wallet/wallet-cli-lib/src/commands/mod.rs @@ -198,6 +198,46 @@ pub enum ColdWalletCommand { transaction: String, }, + /// Signs a challenge with a private key corresponding to the provided address destination. + #[clap(name = "account-sign-challenge-hex")] + SignChallegeHex { + /// Hex encoded message to be signed + message: String, + /// Address with whose private key to sign the challenge + address: String, + }, + + /// Signs a challenge with a private key corresponding to the provided address destination. + #[clap(name = "account-sign-challenge-plain")] + SignChallege { + /// The message to be signed + message: String, + /// Address with whose private key to sign the challenge + address: String, + }, + + /// Verifies a signed challenge against an address destination + #[clap(name = "verify-challenge-hex")] + VerifyChallengeHex { + /// The hex encoded message that was signed + message: String, + /// Hex encoded signed challenge + signed_challenge: String, + /// Address with whose private key the challenge was signed with + address: String, + }, + + /// Verifies a signed challenge against an address destination + #[clap(name = "verify-challenge-plain")] + VerifyChallenge { + /// The message that was signed + message: String, + /// Hex encoded signed challenge + signed_challenge: String, + /// Address with whose private key the challenge was signed with + address: String, + }, + /// Print command history in the wallet for this execution #[clap(name = "history-print")] PrintHistory, @@ -1094,6 +1134,77 @@ where Ok(ConsoleCommand::Print(output_str)) } + ColdWalletCommand::SignChallegeHex { + message: challenge, + address, + } => { + let selected_account = self.get_selected_acc()?; + let challenge = hex::decode(challenge) + .map_err(|err| WalletCliError::InvalidInput(err.to_string()))?; + let result = + self.wallet_rpc.sign_challenge(selected_account, challenge, address).await?; + + Ok(ConsoleCommand::Print(format!( + "The generated hex encoded signature is\n{}", + result.to_hex() + ))) + } + + ColdWalletCommand::SignChallege { + message: challenge, + address, + } => { + let selected_account = self.get_selected_acc()?; + let result = self + .wallet_rpc + .sign_challenge(selected_account, challenge.into_bytes(), address) + .await?; + + Ok(ConsoleCommand::Print(format!( + "The generated hex encoded signature is\n{}", + result.to_hex() + ))) + } + + ColdWalletCommand::VerifyChallengeHex { + message, + signed_challenge, + address, + } => { + let message = hex::decode(message).map_err(|e| { + WalletCliError::InvalidInput(format!("invalid hex data: {}", e)) + })?; + let signed_challenge = hex::decode(signed_challenge).map_err(|e| { + WalletCliError::InvalidInput(format!("invalid hex data: {}", e)) + })?; + + self.wallet_rpc.verify_challenge(message, signed_challenge, address)?; + + Ok(ConsoleCommand::Print( + "The provided signature is correct".to_string(), + )) + } + + ColdWalletCommand::VerifyChallenge { + message, + signed_challenge, + address, + } => { + let signed_challenge = hex::decode(signed_challenge).map_err(|e| { + WalletCliError::InvalidInput(format!("invalid hex data: {}", e)) + })?; + + self.wallet_rpc.verify_challenge( + message.into_bytes(), + signed_challenge, + address, + )?; + + Ok(ConsoleCommand::Print( + "The provided signature is correct".to_string(), + )) + } + ColdWalletCommand::Version => Ok(ConsoleCommand::Print(get_version())), ColdWalletCommand::Exit => { if let Some(rpc) = self.server_rpc.take() { diff --git a/wallet/wallet-controller/src/synced_controller.rs b/wallet/wallet-controller/src/synced_controller.rs index 5ee4014bb0..09d62979f0 100644 --- a/wallet/wallet-controller/src/synced_controller.rs +++ b/wallet/wallet-controller/src/synced_controller.rs @@ -18,6 +18,7 @@ use std::collections::BTreeSet; use common::{ address::Address, chain::{ + signature::inputsig::arbitrary_message::SignedArbitraryMessage, tokens::{ IsTokenFreezable, IsTokenUnfreezable, Metadata, RPCTokenInfo, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply, @@ -640,6 +641,16 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { .map_err(ControllerError::WalletError) } + pub fn sign_challenge( + &mut self, + challenge: Vec, + destination: Destination, + ) -> Result> { + self.wallet + .sign_challenge(self.account_index, challenge, destination) + .map_err(ControllerError::WalletError) + } + async fn get_current_and_consolidation_fee_rate( &mut self, ) -> Result<(mempool::FeeRate, mempool::FeeRate), ControllerError> { diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index 94ea1f169c..0e98926773 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -36,6 +36,9 @@ use wallet::{ use common::{ address::Address, chain::{ + signature::inputsig::arbitrary_message::{ + produce_message_challenge, SignedArbitraryMessage, + }, tokens::{IsTokenFreezable, IsTokenUnfreezable, Metadata, TokenTotalSupply}, Block, ChainConfig, DelegationId, Destination, GenBlock, PoolId, SignedTransaction, Transaction, TxOutput, UtxoOutPoint, @@ -401,6 +404,47 @@ impl WalletRpc { .await? } + pub async fn sign_challenge( + &self, + account_index: U31, + challenge: Vec, + address: String, + ) -> WRpcResult { + let config = ControllerConfig { in_top_x_mb: 5 }; // irrelevant + let destination = Address::from_str(&self.chain_config, &address) + .and_then(|addr| addr.decode_object(&self.chain_config)) + .map_err(|_| RpcError::InvalidAddress)?; + + self.wallet + .call_async(move |controller| { + Box::pin(async move { + controller + .synced_controller(account_index, config) + .await? + .sign_challenge(challenge, destination) + .map_err(RpcError::Controller) + }) + }) + .await? + } + + pub fn verify_challenge( + &self, + message: Vec, + signed_challenge: Vec, + address: String, + ) -> WRpcResult<(), N> { + let destination = Address::from_str(&self.chain_config, &address) + .and_then(|addr| addr.decode_object(&self.chain_config)) + .map_err(|_| RpcError::InvalidAddress)?; + + let message_challenge = produce_message_challenge(&message); + let sig = SignedArbitraryMessage::from_data(signed_challenge); + sig.verify_signature(&self.chain_config, &destination, &message_challenge)?; + + Ok(()) + } + pub async fn send_coins( &self, account_index: U31, diff --git a/wallet/wallet-rpc-lib/src/rpc/types.rs b/wallet/wallet-rpc-lib/src/rpc/types.rs index dbf8d9831f..0a7e920adb 100644 --- a/wallet/wallet-rpc-lib/src/rpc/types.rs +++ b/wallet/wallet-rpc-lib/src/rpc/types.rs @@ -19,6 +19,7 @@ use common::{ address::Address, chain::{ block::timestamp::BlockTimestamp, + signature::DestinationSigError, tokens::{self, IsTokenFreezable, Metadata, TokenCreator}, ChainConfig, DelegationId, Destination, PoolId, Transaction, TxOutput, UtxoOutPoint, }, @@ -88,6 +89,9 @@ pub enum RpcError { #[error("Invalid hex encoded partially signed transaction")] InvalidPartialTransaction, + + #[error("{0}")] + DestinationSigError(#[from] DestinationSigError), } impl From> for rpc::Error {