From 65d4b8c52d2779070d7d5234ae5cc3ba5a685d90 Mon Sep 17 00:00:00 2001 From: Artur Yurii Korchynskyi <42449190+akorchyn@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:44:08 +0200 Subject: [PATCH] feat!: NEP-413 support (#37) * feat: initial implementation of NEP-413 support * nep413 tested and works * removed redundant doc line * added example for nep413 * doctest * renamed method * added test suite without callback * review --- examples/nep_413_signing_message.rs | 33 +++++ src/errors.rs | 6 + src/signer/access_keyfile_signer.rs | 59 -------- src/signer/keystore.rs | 78 +++++----- src/signer/ledger.rs | 38 ++++- src/signer/mod.rs | 220 ++++++++++++++++++++++------ src/signer/secret_key.rs | 34 ++--- 7 files changed, 296 insertions(+), 172 deletions(-) create mode 100644 examples/nep_413_signing_message.rs delete mode 100644 src/signer/access_keyfile_signer.rs diff --git a/examples/nep_413_signing_message.rs b/examples/nep_413_signing_message.rs new file mode 100644 index 0000000..8378905 --- /dev/null +++ b/examples/nep_413_signing_message.rs @@ -0,0 +1,33 @@ +use near_api::*; + +use openssl::rand::rand_bytes; + +#[tokio::main] +async fn main() { + let signer = Signer::from_seed_phrase( + "fatal edge jacket cash hard pass gallery fabric whisper size rain biology", + None, + ) + .unwrap(); + + let mut nonce = [0u8; 32]; + rand_bytes(&mut nonce).unwrap(); + + let payload = near_api::signer::NEP413Payload { + message: "Hello NEAR!".to_string(), + nonce, + recipient: "example.near".to_string(), + callback_url: None, + }; + + let signature = signer + .sign_message_nep413( + "round-toad.testnet".parse().unwrap(), + signer.get_public_key().unwrap(), + payload, + ) + .await + .unwrap(); + + println!("Signature: {}", signature); +} diff --git a/src/errors.rs b/src/errors.rs index c743855..46216e4 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -48,6 +48,8 @@ pub enum SignerError { SecretKeyIsNotAvailable, #[error("Failed to fetch nonce: {0}")] FetchNonceError(#[from] QueryError), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), #[cfg(feature = "ledger")] #[error(transparent)] @@ -70,6 +72,8 @@ pub enum AccessKeyFileError { ParseError(#[from] serde_json::Error), #[error(transparent)] SecretError(#[from] SecretError), + #[error("Public key is not linked to the private key")] + PrivatePublicKeyMismatch, } #[cfg(feature = "keystore")] @@ -83,6 +87,8 @@ pub enum KeyStoreError { ParseError(#[from] serde_json::Error), #[error(transparent)] SecretError(#[from] SecretError), + #[error("Task execution error: {0}")] + TaskExecutionError(#[from] tokio::task::JoinError), } #[cfg(feature = "ledger")] diff --git a/src/signer/access_keyfile_signer.rs b/src/signer/access_keyfile_signer.rs deleted file mode 100644 index b618873..0000000 --- a/src/signer/access_keyfile_signer.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::path::PathBuf; - -use near_crypto::{PublicKey, SecretKey}; -use near_primitives::{transaction::Transaction, types::Nonce}; -use tracing::{debug, instrument, trace}; - -use super::{AccountKeyPair, SignerTrait}; -use crate::{ - errors::{AccessKeyFileError, SignerError}, - types::{transactions::PrepopulateTransaction, CryptoHash}, -}; - -const ACCESS_KEYFILE_SIGNER_TARGET: &str = "near_api::signer::access_keyfile"; - -#[derive(Debug, Clone)] -pub struct AccessKeyFileSigner { - keypair: AccountKeyPair, -} - -impl AccessKeyFileSigner { - #[instrument(skip(path), fields(path = %path.display()))] - pub fn new(path: PathBuf) -> Result { - let keypair = AccountKeyPair::load_access_key_file(&path)?; - debug!(target: ACCESS_KEYFILE_SIGNER_TARGET, "Access key file loaded successfully"); - - Ok(Self { keypair }) - } -} - -#[async_trait::async_trait] -impl SignerTrait for AccessKeyFileSigner { - #[instrument(skip(self, tr), fields(signer_id = %tr.signer_id, receiver_id = %tr.receiver_id))] - fn tx_and_secret( - &self, - tr: PrepopulateTransaction, - public_key: PublicKey, - nonce: Nonce, - block_hash: CryptoHash, - ) -> Result<(Transaction, SecretKey), SignerError> { - debug!(target: ACCESS_KEYFILE_SIGNER_TARGET, "Creating transaction"); - let mut transaction = Transaction::new_v0( - tr.signer_id.clone(), - public_key, - tr.receiver_id, - nonce, - block_hash.into(), - ); - *transaction.actions_mut() = tr.actions; - - trace!(target: ACCESS_KEYFILE_SIGNER_TARGET, "Transaction created, returning with secret key"); - Ok((transaction, self.keypair.private_key.to_owned())) - } - - #[instrument(skip(self))] - fn get_public_key(&self) -> Result { - debug!(target: ACCESS_KEYFILE_SIGNER_TARGET, "Retrieving public key"); - Ok(self.keypair.public_key.clone()) - } -} diff --git a/src/signer/keystore.rs b/src/signer/keystore.rs index 9a702d8..634fa0f 100644 --- a/src/signer/keystore.rs +++ b/src/signer/keystore.rs @@ -1,15 +1,11 @@ +use futures::future::join_all; use near_crypto::{PublicKey, SecretKey}; -use near_primitives::{ - transaction::Transaction, - types::{AccountId, Nonce}, - views::AccessKeyPermissionView, -}; +use near_primitives::{types::AccountId, views::AccessKeyPermissionView}; use tracing::{debug, info, instrument, trace, warn}; use crate::{ config::NetworkConfig, errors::{KeyStoreError, SignerError}, - types::{transactions::PrepopulateTransaction, CryptoHash}, }; use super::{AccountKeyPair, SignerTrait}; @@ -23,38 +19,31 @@ pub struct KeystoreSigner { #[async_trait::async_trait] impl SignerTrait for KeystoreSigner { - #[instrument(skip(self, tr), fields(signer_id = %tr.signer_id, receiver_id = %tr.receiver_id))] - fn tx_and_secret( + #[instrument(skip(self))] + async fn get_secret_key( &self, - tr: PrepopulateTransaction, - public_key: PublicKey, - nonce: Nonce, - block_hash: CryptoHash, - ) -> Result<(Transaction, SecretKey), SignerError> { + signer_id: &AccountId, + public_key: &PublicKey, + ) -> Result { debug!(target: KEYSTORE_SIGNER_TARGET, "Searching for matching public key"); self.potential_pubkeys .iter() - .find(|key| *key == &public_key) + .find(|key| *key == public_key) .ok_or(SignerError::PublicKeyIsNotAvailable)?; info!(target: KEYSTORE_SIGNER_TARGET, "Retrieving secret key"); // TODO: fix this. Well the search is a bit suboptimal, but it's not a big deal for now - let secret = Self::get_secret_key(&tr.signer_id, &public_key, "mainnet") - .or_else(|_| Self::get_secret_key(&tr.signer_id, &public_key, "testnet")) - .map_err(|_| SignerError::SecretKeyIsNotAvailable)?; - - debug!(target: KEYSTORE_SIGNER_TARGET, "Creating transaction"); - let mut transaction = Transaction::new_v0( - tr.signer_id.clone(), - public_key, - tr.receiver_id, - nonce, - block_hash.into(), - ); - *transaction.actions_mut() = tr.actions; - - info!(target: KEYSTORE_SIGNER_TARGET, "Transaction and secret key prepared successfully"); - Ok((transaction, secret.private_key)) + let secret = + if let Ok(secret) = Self::get_secret_key(signer_id, public_key, "mainnet").await { + secret + } else { + Self::get_secret_key(signer_id, public_key, "testnet") + .await + .map_err(|_| SignerError::SecretKeyIsNotAvailable)? + }; + + info!(target: KEYSTORE_SIGNER_TARGET, "Secret key prepared successfully"); + Ok(secret.private_key) } #[instrument(skip(self))] @@ -87,16 +76,16 @@ impl KeystoreSigner { .await?; debug!(target: KEYSTORE_SIGNER_TARGET, "Filtering and collecting potential public keys"); - let potential_pubkeys: Vec = account_keys + let potential_pubkeys = account_keys .keys - .into_iter() + .iter() // TODO: support functional access keys .filter(|key| key.access_key.permission == AccessKeyPermissionView::FullAccess) - .flat_map(|key| { - Self::get_secret_key(&account_id, &key.public_key, &network.network_name) - .map(|keypair| keypair.public_key) - .ok() - }) + .map(|key| Self::get_secret_key(&account_id, &key.public_key, &network.network_name)); + let potential_pubkeys: Vec = join_all(potential_pubkeys) + .await + .into_iter() + .flat_map(|result| result.map(|keypair| keypair.public_key).ok()) .collect(); info!(target: KEYSTORE_SIGNER_TARGET, "KeystoreSigner created with {} potential public keys", potential_pubkeys.len()); @@ -104,7 +93,7 @@ impl KeystoreSigner { } #[instrument(skip(public_key), fields(account_id = %account_id, network_name = %network_name))] - fn get_secret_key( + async fn get_secret_key( account_id: &AccountId, public_key: &PublicKey, network_name: &str, @@ -112,10 +101,17 @@ impl KeystoreSigner { trace!(target: KEYSTORE_SIGNER_TARGET, "Retrieving secret key from keyring"); let service_name = std::borrow::Cow::Owned(format!("near-{}-{}", network_name, account_id.as_str())); + let user = format!("{}:{}", account_id, public_key); + + // This can be a blocking operation (for example, if the keyring is locked in the OS and user needs to unlock it), + // so we need to spawn a new task to get the password + let password = tokio::task::spawn_blocking(move || { + let password = keyring::Entry::new(&service_name, &user)?.get_password()?; - let password = - keyring::Entry::new(&service_name, &format!("{}:{}", account_id, public_key))? - .get_password()?; + Ok::<_, KeyStoreError>(password) + }) + .await + .unwrap_or_else(|tokio_join_error| Err(KeyStoreError::from(tokio_join_error)))?; debug!(target: KEYSTORE_SIGNER_TARGET, "Deserializing account key pair"); Ok(serde_json::from_str(&password)?) diff --git a/src/signer/ledger.rs b/src/signer/ledger.rs index d540b1e..77d91ef 100644 --- a/src/signer/ledger.rs +++ b/src/signer/ledger.rs @@ -10,7 +10,7 @@ use crate::{ types::{transactions::PrepopulateTransaction, CryptoHash}, }; -use super::SignerTrait; +use super::{NEP413Payload, SignerTrait}; const LEDGER_SIGNER_TARGET: &str = "near_api::signer::ledger"; @@ -130,13 +130,39 @@ impl SignerTrait for LedgerSigner { }) } - fn tx_and_secret( + #[instrument(skip(self), fields(signer_id = %_signer_id, receiver_id = %payload.recipient, message = %payload.message))] + async fn sign_message_nep413( &self, - _tr: PrepopulateTransaction, + _signer_id: crate::AccountId, _public_key: PublicKey, - _nonce: Nonce, - _block_hash: CryptoHash, - ) -> Result<(Transaction, SecretKey), SignerError> { + payload: NEP413Payload, + ) -> Result { + info!(target: LEDGER_SIGNER_TARGET, "Signing NEP413 message with Ledger"); + let hd_path = self.hd_path.clone(); + let payload = payload.into(); + + let signature: Vec = tokio::task::spawn_blocking(move || { + let signature = + near_ledger::sign_message_nep413(&payload, hd_path).map_err(LedgerError::from)?; + + Ok::<_, LedgerError>(signature) + }) + .await + .unwrap_or_else(|tokio_join_error| Err(LedgerError::from(tokio_join_error)))?; + + debug!(target: LEDGER_SIGNER_TARGET, "Creating Signature object for NEP413"); + let signature = + near_crypto::Signature::from_parts(near_crypto::KeyType::ED25519, &signature) + .map_err(LedgerError::from)?; + + Ok(signature) + } + + async fn get_secret_key( + &self, + _signer_id: &crate::AccountId, + _public_key: &PublicKey, + ) -> Result { warn!(target: LEDGER_SIGNER_TARGET, "Attempted to access secret key, which is not available for Ledger signer"); Err(SignerError::SecretKeyIsNotAvailable) } diff --git a/src/signer/mod.rs b/src/signer/mod.rs index c617585..2df904c 100644 --- a/src/signer/mod.rs +++ b/src/signer/mod.rs @@ -119,13 +119,15 @@ use std::{ }, }; -use near_crypto::{ED25519SecretKey, PublicKey, SecretKey}; +use borsh::{BorshDeserialize, BorshSerialize}; +use near_crypto::{ED25519SecretKey, PublicKey, SecretKey, Signature}; use near_primitives::{ action::delegate::SignedDelegateAction, + hash::hash, transaction::{SignedTransaction, Transaction}, types::{AccountId, BlockHeight, Nonce}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use slipped10::BIP32Path; use tracing::{debug, info, instrument, trace, warn}; @@ -135,9 +137,8 @@ use crate::{ types::{transactions::PrepopulateTransaction, CryptoHash}, }; -use self::{access_keyfile_signer::AccessKeyFileSigner, secret_key::SecretKeySigner}; +use secret_key::SecretKeySigner; -pub mod access_keyfile_signer; #[cfg(feature = "keystore")] pub mod keystore; #[cfg(feature = "ledger")] @@ -152,7 +153,7 @@ pub const DEFAULT_WORD_COUNT: usize = 12; /// A struct representing a pair of public and private keys for an account. /// This might be useful for getting keys from a file. E.g. `~/.near-credentials`. -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct AccountKeyPair { pub public_key: near_crypto::PublicKey, pub private_key: near_crypto::SecretKey, @@ -165,6 +166,31 @@ impl AccountKeyPair { } } +/// [NEP413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md) input for the signing message. +#[derive(Debug, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +pub struct NEP413Payload { + /// The message that wants to be transmitted. + pub message: String, + /// A nonce that uniquely identifies this instance of the message, denoted as a 32 bytes array. + pub nonce: [u8; 32], + /// The recipient to whom the message is destined (e.g. "alice.near" or "myapp.com"). + pub recipient: String, + /// A callback URL that will be called with the signed message as a query parameter. + pub callback_url: Option, +} + +#[cfg(feature = "ledger")] +impl From for near_ledger::NEP413Payload { + fn from(payload: NEP413Payload) -> Self { + Self { + messsage: payload.message, + nonce: payload.nonce, + recipient: payload.recipient, + callback_url: payload.callback_url, + } + } +} + /// A trait for implementing custom signing logic. /// /// This trait provides the core functionality needed to sign transactions and delegate actions. @@ -174,7 +200,7 @@ impl AccountKeyPair { /// /// ## Implementing a custom signer /// ```rust,no_run -/// use near_api::{signer::*, types::{transactions::PrepopulateTransaction, CryptoHash}, errors::SignerError}; +/// use near_api::{AccountId, signer::*, types::{transactions::PrepopulateTransaction, CryptoHash}, errors::SignerError}; /// use near_crypto::{PublicKey, SecretKey}; /// use near_primitives::transaction::Transaction; /// @@ -184,22 +210,12 @@ impl AccountKeyPair { /// /// #[async_trait::async_trait] /// impl SignerTrait for CustomSigner { -/// fn tx_and_secret( +/// async fn get_secret_key( /// &self, -/// tr: PrepopulateTransaction, -/// public_key: PublicKey, -/// nonce: u64, -/// block_hash: CryptoHash, -/// ) -> Result<(Transaction, SecretKey), SignerError> { -/// let mut transaction = Transaction::new_v0( -/// tr.signer_id.clone(), -/// public_key, -/// tr.receiver_id, -/// nonce, -/// block_hash.into(), -/// ); -/// *transaction.actions_mut() = tr.actions; -/// Ok((transaction, self.secret_key.clone())) +/// _signer_id: &AccountId, +/// _public_key: &PublicKey +/// ) -> Result { +/// Ok(self.secret_key.clone()) /// } /// /// fn get_public_key(&self) -> Result { @@ -210,7 +226,7 @@ impl AccountKeyPair { /// /// ## Using a custom signer /// ```rust,no_run -/// # use near_api::{signer::*, types::{transactions::PrepopulateTransaction, CryptoHash}, errors::SignerError}; +/// # use near_api::{AccountId, signer::*, types::{transactions::PrepopulateTransaction, CryptoHash}, errors::SignerError}; /// # use near_crypto::{PublicKey, SecretKey}; /// # struct CustomSigner; /// # impl CustomSigner { @@ -218,8 +234,7 @@ impl AccountKeyPair { /// # } /// # #[async_trait::async_trait] /// # impl SignerTrait for CustomSigner { -/// # fn tx_and_secret(&self, _: PrepopulateTransaction, _: PublicKey, _: u64, _: CryptoHash, -/// # ) -> Result<(near_primitives::transaction::Transaction, SecretKey), SignerError> { unimplemented!() } +/// # async fn get_secret_key(&self, _: &AccountId, _: &near_crypto::PublicKey) -> Result { unimplemented!() } /// # fn get_public_key(&self) -> Result { unimplemented!() } /// # } /// # async fn example() -> Result<(), Box> { @@ -242,6 +257,7 @@ pub trait SignerTrait { /// The delegate action is signed with a maximum block height to ensure the delegation expiration after some point in time. /// /// The default implementation should work for most cases. + #[instrument(skip(self, tr), fields(signer_id = %tr.signer_id, receiver_id = %tr.receiver_id))] async fn sign_meta( &self, tr: PrepopulateTransaction, @@ -250,8 +266,15 @@ pub trait SignerTrait { block_hash: CryptoHash, max_block_height: BlockHeight, ) -> Result { - let (unsigned_transaction, signer_secret_key) = - self.tx_and_secret(tr, public_key, nonce, block_hash)?; + let signer_secret_key = self.get_secret_key(&tr.signer_id, &public_key).await?; + let mut unsigned_transaction = Transaction::new_v0( + tr.signer_id.clone(), + public_key, + tr.receiver_id, + nonce, + block_hash.into(), + ); + *unsigned_transaction.actions_mut() = tr.actions; get_signed_delegate_action(unsigned_transaction, signer_secret_key, max_block_height) } @@ -262,6 +285,7 @@ pub trait SignerTrait { /// that can be sent to the `NEAR` network. /// /// The default implementation should work for most cases. + #[instrument(skip(self, tr), fields(signer_id = %tr.signer_id, receiver_id = %tr.receiver_id))] async fn sign( &self, tr: PrepopulateTransaction, @@ -269,26 +293,52 @@ pub trait SignerTrait { nonce: Nonce, block_hash: CryptoHash, ) -> Result { - let (unsigned_transaction, signer_secret_key) = - self.tx_and_secret(tr, public_key, nonce, block_hash)?; + let signer_secret_key = self.get_secret_key(&tr.signer_id, &public_key).await?; + let mut unsigned_transaction = Transaction::new_v0( + tr.signer_id.clone(), + public_key, + tr.receiver_id, + nonce, + block_hash.into(), + ); + *unsigned_transaction.actions_mut() = tr.actions; + let signature = signer_secret_key.sign(unsigned_transaction.get_hash_and_size().0.as_ref()); Ok(SignedTransaction::new(signature, unsigned_transaction)) } - /// Creates an unsigned transaction and returns it along with the secret key. - /// This is a `helper` method that should be implemented by the signer or fail with SignerError. - /// As long as this method works, the default implementation of the [sign_meta](`SignerTrait::sign_meta`) and [sign](`SignerTrait::sign`) methods should work. + /// Signs a [NEP413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md) message that is widely used for the [authentication](https://docs.near.org/build/web3-apps/backend/) + /// and offchain proof of account ownership. /// - /// If you can't provide a SecretKey for some reason (E.g. `Ledger``), - /// you can fail with SignerError and override `sign_meta` and `sign` methods. - fn tx_and_secret( + /// The default implementation should work for most cases. + #[instrument(skip(self), fields(signer_id = %signer_id, receiver_id = %payload.recipient, message = %payload.message))] + async fn sign_message_nep413( &self, - tr: PrepopulateTransaction, + signer_id: AccountId, public_key: PublicKey, - nonce: Nonce, - block_hash: CryptoHash, - ) -> Result<(Transaction, SecretKey), SignerError>; + payload: NEP413Payload, + ) -> Result { + const NEP413_413_SIGN_MESSAGE_PREFIX: u32 = (1u32 << 31u32) + 413u32; + let mut bytes = NEP413_413_SIGN_MESSAGE_PREFIX.to_le_bytes().to_vec(); + borsh::to_writer(&mut bytes, &payload)?; + let hash = hash(&bytes); + let secret = self.get_secret_key(&signer_id, &public_key).await?; + let signature = secret.sign(hash.as_ref()); + Ok(signature) + } + + /// Returns the secret key associated with this signer. + /// This is a `helper` method that should be implemented by the signer or fail with [`SignerError`]. + /// As long as this method works, the default implementation of the [sign_meta](`SignerTrait::sign_meta`) and [sign](`SignerTrait::sign`) methods should work. + /// + /// If you can't provide a [`SecretKey`] for some reason (E.g. `Ledger``), + /// you can fail with SignerError and override `sign_meta` and `sign`, `sign_message_nep413` methods. + async fn get_secret_key( + &self, + signer_id: &AccountId, + public_key: &PublicKey, + ) -> Result; /// Returns the public key associated with this signer. /// @@ -406,9 +456,16 @@ impl Signer { Ok(SecretKeySigner::new(secret_key)) } - /// Creates a [AccessKeyFileSigner](`AccessKeyFileSigner`) using a path to the access key file. - pub fn from_access_keyfile(path: PathBuf) -> Result { - AccessKeyFileSigner::new(path) + /// Creates a [SecretKeySigner](`secret_key::SecretKeySigner`) using a path to the access key file. + pub fn from_access_keyfile(path: PathBuf) -> Result { + let keypair = AccountKeyPair::load_access_key_file(&path)?; + debug!(target: SIGNER_TARGET, "Access key file loaded successfully"); + + if keypair.public_key != keypair.private_key.public_key() { + return Err(AccessKeyFileError::PrivatePublicKeyMismatch); + } + + Ok(SecretKeySigner::new(keypair.private_key)) } /// Creates a [LedgerSigner](`ledger::LedgerSigner`) using default HD path. @@ -625,3 +682,84 @@ pub fn generate_secret_key_from_seed_phrase(seed_phrase: String) -> Result Result<(Transaction, SecretKey), SignerError> { - debug!(target: SECRET_KEY_SIGNER_TARGET, "Creating transaction"); - let mut transaction = Transaction::new_v0( - tr.signer_id.clone(), - public_key, - tr.receiver_id, - nonce, - block_hash.into(), - ); - *transaction.actions_mut() = tr.actions; - - trace!(target: SECRET_KEY_SIGNER_TARGET, "Transaction created, returning with secret key"); - Ok((transaction, self.secret_key.clone())) + signer_id: &crate::AccountId, + public_key: &PublicKey, + ) -> Result { + trace!(target: SECRET_KEY_SIGNER_TARGET, "returning with secret key"); + Ok(self.secret_key.clone()) } #[instrument(skip(self))]