diff --git a/Cargo.lock b/Cargo.lock index 21332b840a..1808274fc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,9 +65,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" +checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783" dependencies = [ "memchr", ] @@ -338,6 +338,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5353f36341f7451062466f0b755b96ac3a9547e4d7f6b70d603fc721a7d7896" + [[package]] name = "bumpalo" version = "3.13.0" @@ -1633,6 +1639,7 @@ dependencies = [ "async-trait", "bech32 0.9.1", "bitflags 2.4.0", + "bs58", "bytemuck", "derive_builder", "derive_more", diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index 9523b58f98..6f6a2d0eff 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `migrate_db_chrysalis_to_stardust()` function; - `Wallet::get_chrysalis_data()` method; +- `PrivateKeySecretManager` and `SecretManager::PrivateKey`; +- `SecretManager::from` impl for variants; ### Fixed diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 0e9f3a34e5..791550ea69 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -58,6 +58,7 @@ serde_json = { version = "1.0.105", default-features = false, features = [ # Optional dependencies anymap = { version = "0.12.1", default-features = false, optional = true } async-trait = { version = "0.1.73", default-features = false, optional = true } +bs58 = { version = "0.5.0", default-features = false, optional = true } derive_builder = { version = "0.12.0", default-features = false, optional = true } fern-logger = { version = "0.5.0", default-features = false, optional = true } futures = { version = "0.3.28", default-features = false, features = [ @@ -185,6 +186,7 @@ stronghold = [ "dep:heck", ] tls = ["reqwest?/rustls-tls", "rumqttc?/use-rustls"] +private_key_secret_manager = ["bs58"] client = [ "pow", diff --git a/sdk/src/client/secret/mod.rs b/sdk/src/client/secret/mod.rs index 784c02aa7a..25f3738c2b 100644 --- a/sdk/src/client/secret/mod.rs +++ b/sdk/src/client/secret/mod.rs @@ -3,12 +3,17 @@ //! Secret manager module enabling address generation and transaction essence signing. +/// Module for ledger nano based secret management. #[cfg(feature = "ledger_nano")] #[cfg_attr(docsrs, doc(cfg(feature = "ledger_nano")))] pub mod ledger_nano; -/// Module for signing with a mnemonic or seed +/// Module for mnemonic based secret management. pub mod mnemonic; -/// Module for signing with a Stronghold vault +/// Module for single private key based secret management. +#[cfg(feature = "private_key_secret_manager")] +#[cfg_attr(docsrs, doc(cfg(feature = "private_key_secret_manager")))] +pub mod private_key; +/// Module for stronghold based secret management. #[cfg(feature = "stronghold")] #[cfg_attr(docsrs, doc(cfg(feature = "stronghold")))] pub mod stronghold; @@ -30,6 +35,8 @@ use zeroize::Zeroizing; #[cfg(feature = "ledger_nano")] use self::ledger_nano::LedgerSecretManager; use self::mnemonic::MnemonicSecretManager; +#[cfg(feature = "private_key_secret_manager")] +use self::private_key::PrivateKeySecretManager; #[cfg(feature = "stronghold")] use self::stronghold::StrongholdSecretManager; pub use self::types::{GenerateAddressOptions, LedgerNanoStatus}; @@ -137,11 +144,43 @@ pub enum SecretManager { /// LedgerNano or Stronghold instead. Mnemonic(MnemonicSecretManager), + /// Secret manager that uses a single private key. + #[cfg(feature = "private_key_secret_manager")] + #[cfg_attr(docsrs, doc(cfg(feature = "private_key_secret_manager")))] + PrivateKey(Box), + /// Secret manager that's just a placeholder, so it can be provided to an online wallet, but can't be used for /// signing. Placeholder, } +#[cfg(feature = "stronghold")] +impl From for SecretManager { + fn from(secret_manager: StrongholdSecretManager) -> Self { + Self::Stronghold(secret_manager) + } +} + +#[cfg(feature = "ledger_nano")] +impl From for SecretManager { + fn from(secret_manager: LedgerSecretManager) -> Self { + Self::LedgerNano(secret_manager) + } +} + +impl From for SecretManager { + fn from(secret_manager: MnemonicSecretManager) -> Self { + Self::Mnemonic(secret_manager) + } +} + +#[cfg(feature = "private_key_secret_manager")] +impl From for SecretManager { + fn from(secret_manager: PrivateKeySecretManager) -> Self { + Self::PrivateKey(Box::new(secret_manager)) + } +} + impl Debug for SecretManager { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -150,6 +189,8 @@ impl Debug for SecretManager { #[cfg(feature = "ledger_nano")] Self::LedgerNano(_) => f.debug_tuple("LedgerNano").field(&"...").finish(), Self::Mnemonic(_) => f.debug_tuple("Mnemonic").field(&"...").finish(), + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(_) => f.debug_tuple("PrivateKey").field(&"...").finish(), Self::Placeholder => f.debug_struct("Placeholder").finish(), } } @@ -180,6 +221,11 @@ pub enum SecretManagerDto { /// Mnemonic #[serde(alias = "mnemonic")] Mnemonic(Zeroizing), + /// Private Key + #[cfg(feature = "private_key_secret_manager")] + #[cfg_attr(docsrs, doc(cfg(feature = "private_key_secret_manager")))] + #[serde(alias = "privateKey")] + PrivateKey(Zeroizing), /// Hex seed #[serde(alias = "hexSeed")] HexSeed(Zeroizing), @@ -215,6 +261,11 @@ impl TryFrom for SecretManager { Self::Mnemonic(MnemonicSecretManager::try_from_mnemonic(mnemonic.as_str().to_owned())?) } + #[cfg(feature = "private_key_secret_manager")] + SecretManagerDto::PrivateKey(private_key) => { + Self::PrivateKey(Box::new(PrivateKeySecretManager::try_from_hex(private_key)?)) + } + SecretManagerDto::HexSeed(hex_seed) => { // `SecretManagerDto` is `ZeroizeOnDrop` so it will take care of zeroizing the original. Self::Mnemonic(MnemonicSecretManager::try_from_hex_seed(hex_seed)?) @@ -247,6 +298,10 @@ impl From<&SecretManager> for SecretManagerDto { // the client/wallet we also don't need to convert it in this direction with the mnemonic/seed, we only need // to know the type SecretManager::Mnemonic(_mnemonic) => Self::Mnemonic("...".to_string().into()), + + #[cfg(feature = "private_key_secret_manager")] + SecretManager::PrivateKey(_private_key) => Self::PrivateKey("...".to_string().into()), + SecretManager::Placeholder => Self::Placeholder, } } @@ -277,6 +332,12 @@ impl SecretManage for SecretManager { .generate_ed25519_addresses(coin_type, account_index, address_indexes, options) .await } + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => { + secret_manager + .generate_ed25519_addresses(coin_type, account_index, address_indexes, options) + .await + } Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -302,6 +363,12 @@ impl SecretManage for SecretManager { .generate_evm_addresses(coin_type, account_index, address_indexes, options) .await } + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => { + secret_manager + .generate_evm_addresses(coin_type, account_index, address_indexes, options) + .await + } Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -313,6 +380,8 @@ impl SecretManage for SecretManager { #[cfg(feature = "ledger_nano")] Self::LedgerNano(secret_manager) => Ok(secret_manager.sign_ed25519(msg, chain).await?), Self::Mnemonic(secret_manager) => secret_manager.sign_ed25519(msg, chain).await, + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => secret_manager.sign_ed25519(msg, chain).await, Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -328,6 +397,8 @@ impl SecretManage for SecretManager { #[cfg(feature = "ledger_nano")] Self::LedgerNano(secret_manager) => Ok(secret_manager.sign_secp256k1_ecdsa(msg, chain).await?), Self::Mnemonic(secret_manager) => secret_manager.sign_secp256k1_ecdsa(msg, chain).await, + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => secret_manager.sign_secp256k1_ecdsa(msg, chain).await, Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -351,6 +422,12 @@ impl SecretManage for SecretManager { .sign_transaction_essence(prepared_transaction_data, time) .await } + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => { + secret_manager + .sign_transaction_essence(prepared_transaction_data, time) + .await + } Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -365,6 +442,8 @@ impl SecretManage for SecretManager { #[cfg(feature = "ledger_nano")] Self::LedgerNano(secret_manager) => Ok(secret_manager.sign_transaction(prepared_transaction_data).await?), Self::Mnemonic(secret_manager) => secret_manager.sign_transaction(prepared_transaction_data).await, + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(secret_manager) => secret_manager.sign_transaction(prepared_transaction_data).await, Self::Placeholder => Err(Error::PlaceholderSecretManager), } } @@ -390,6 +469,8 @@ impl SecretManagerConfig for SecretManager { #[cfg(feature = "ledger_nano")] Self::LedgerNano(s) => s.to_config().map(Self::Config::LedgerNano), Self::Mnemonic(_) => None, + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(_) => None, Self::Placeholder => None, } } @@ -406,6 +487,10 @@ impl SecretManagerConfig for SecretManager { SecretManagerDto::Mnemonic(mnemonic) => { Self::Mnemonic(MnemonicSecretManager::try_from_mnemonic(mnemonic.as_str().to_owned())?) } + #[cfg(feature = "private_key_secret_manager")] + SecretManagerDto::PrivateKey(private_key) => { + Self::PrivateKey(Box::new(PrivateKeySecretManager::try_from_hex(private_key.to_owned())?)) + } SecretManagerDto::Placeholder => Self::Placeholder, }) } diff --git a/sdk/src/client/secret/private_key.rs b/sdk/src/client/secret/private_key.rs new file mode 100644 index 0000000000..7b11ab0ce9 --- /dev/null +++ b/sdk/src/client/secret/private_key.rs @@ -0,0 +1,132 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Implementation of [`PrivateKeySecretManager`]. + +use std::ops::Range; + +use async_trait::async_trait; +use crypto::{ + hashes::{blake2b::Blake2b256, Digest}, + keys::bip44::Bip44, + signatures::{ + ed25519, + secp256k1_ecdsa::{self, EvmAddress}, + }, +}; +use zeroize::{Zeroize, Zeroizing}; + +use super::{GenerateAddressOptions, SecretManage}; +use crate::{ + client::{api::PreparedTransactionData, Error}, + types::block::{ + address::Ed25519Address, payload::transaction::TransactionPayload, signature::Ed25519Signature, unlock::Unlocks, + }, +}; + +/// Secret manager based on a single private key. +pub struct PrivateKeySecretManager(ed25519::SecretKey); + +impl std::fmt::Debug for PrivateKeySecretManager { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_tuple("PrivateKeySecretManager").finish() + } +} + +#[async_trait] +impl SecretManage for PrivateKeySecretManager { + type Error = Error; + + async fn generate_ed25519_addresses( + &self, + _coin_type: u32, + _account_index: u32, + _address_indexes: Range, + _options: impl Into> + Send, + ) -> Result, Self::Error> { + let public_key = self.0.public_key().to_bytes(); + + // Hash the public key to get the address + let result = Blake2b256::digest(public_key).try_into().map_err(|_e| { + crate::client::Error::Blake2b256("hashing the public key while generating the address failed.") + })?; + + crate::client::Result::Ok(vec![Ed25519Address::new(result)]) + } + + async fn generate_evm_addresses( + &self, + _coin_type: u32, + _account_index: u32, + _address_indexes: Range, + _options: impl Into> + Send, + ) -> Result, Self::Error> { + // TODO replace with a more fitting variant. + Err(Error::SecretManagerMismatch) + } + + async fn sign_ed25519(&self, msg: &[u8], _chain: Bip44) -> Result { + let public_key = self.0.public_key(); + let signature = self.0.sign(msg); + + Ok(Ed25519Signature::new(public_key, signature)) + } + + async fn sign_secp256k1_ecdsa( + &self, + _msg: &[u8], + _chain: Bip44, + ) -> Result<(secp256k1_ecdsa::PublicKey, secp256k1_ecdsa::RecoverableSignature), Self::Error> { + // TODO replace with a more fitting variant. + Err(Error::SecretManagerMismatch) + } + + async fn sign_transaction_essence( + &self, + prepared_transaction_data: &PreparedTransactionData, + time: Option, + ) -> Result { + super::default_sign_transaction_essence(self, prepared_transaction_data, time).await + } + + async fn sign_transaction( + &self, + prepared_transaction_data: PreparedTransactionData, + ) -> Result { + super::default_sign_transaction(self, prepared_transaction_data).await + } +} + +impl PrivateKeySecretManager { + /// Create a new [`PrivateKeySecretManager`] from a base 58 encoded private key. + pub fn try_from_b58>(b58: T) -> Result { + let mut bytes = [0u8; ed25519::SecretKey::LENGTH]; + + // TODO replace with a more fitting variant. + if bs58::decode(b58.as_ref()) + .onto(&mut bytes) + .map_err(|_| crypto::Error::PrivateKeyError)? + != ed25519::SecretKey::LENGTH + { + // TODO replace with a more fitting variant. + return Err(crypto::Error::PrivateKeyError.into()); + } + + let private_key = Self(ed25519::SecretKey::from_bytes(&bytes)); + + bytes.zeroize(); + + Ok(private_key) + } + + /// Create a new [`PrivateKeySecretManager`] from an hex encoded private key. + pub fn try_from_hex(hex: impl Into>) -> Result { + let mut bytes = prefix_hex::decode(hex.into())?; + + let private_key = Self(ed25519::SecretKey::from_bytes(&bytes)); + + bytes.zeroize(); + + Ok(private_key) + } +} diff --git a/sdk/src/wallet/core/operations/address_generation.rs b/sdk/src/wallet/core/operations/address_generation.rs index 494894fddd..f84277de38 100644 --- a/sdk/src/wallet/core/operations/address_generation.rs +++ b/sdk/src/wallet/core/operations/address_generation.rs @@ -109,6 +109,17 @@ impl Wallet { ) .await? } + #[cfg(feature = "private_key_secret_manager")] + SecretManager::PrivateKey(private_key) => { + private_key + .generate_ed25519_addresses( + self.coin_type.load(Ordering::Relaxed), + account_index, + address_index..address_index + 1, + options, + ) + .await? + } SecretManager::Placeholder => return Err(crate::client::Error::PlaceholderSecretManager.into()), }; diff --git a/sdk/tests/client/secret_manager/mnemonic.rs b/sdk/tests/client/secret_manager/mnemonic.rs new file mode 100644 index 0000000000..453fc9861c --- /dev/null +++ b/sdk/tests/client/secret_manager/mnemonic.rs @@ -0,0 +1,29 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk::client::{ + api::GetAddressesOptions, constants::SHIMMER_TESTNET_BECH32_HRP, secret::SecretManager, Result, +}; + +#[tokio::test] +async fn mnemonic_secret_manager() -> Result<()> { + let dto = r#"{"mnemonic": "acoustic trophy damage hint search taste love bicycle foster cradle brown govern endless depend situate athlete pudding blame question genius transfer van random vast"}"#; + let secret_manager: SecretManager = dto.parse()?; + + let addresses = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) + .with_account_index(0) + .with_range(0..1), + ) + .await + .unwrap(); + + assert_eq!( + addresses[0], + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" + ); + + Ok(()) +} diff --git a/sdk/tests/client/secret_manager/mod.rs b/sdk/tests/client/secret_manager/mod.rs new file mode 100644 index 0000000000..4e2a7988d5 --- /dev/null +++ b/sdk/tests/client/secret_manager/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod mnemonic; +#[cfg(feature = "private_key_secret_manager")] +mod private_key; +#[cfg(feature = "stronghold")] +mod stronghold; diff --git a/sdk/tests/client/secret_manager/private_key.rs b/sdk/tests/client/secret_manager/private_key.rs new file mode 100644 index 0000000000..31c8d6f10b --- /dev/null +++ b/sdk/tests/client/secret_manager/private_key.rs @@ -0,0 +1,84 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk::client::{ + api::GetAddressesOptions, + constants::SHIMMER_TESTNET_BECH32_HRP, + secret::{private_key::PrivateKeySecretManager, SecretManager}, + Result, +}; + +#[tokio::test] +async fn private_key_secret_manager_hex() -> Result<()> { + let dto = r#"{"privateKey": "0x9e845b327c44e28bdd206c7c9eff09c40680bc2512add57280baf5b064d7e6f6"}"#; + let secret_manager: SecretManager = dto.parse()?; + + let address_0 = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) + .with_account_index(0) + .with_range(0..1), + ) + .await + .unwrap()[0]; + // Changing range generates the same address. + let address_1 = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) + .with_account_index(0) + .with_range(1..2), + ) + .await + .unwrap()[0]; + // Changing account generates the same address. + let address_2 = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) + .with_account_index(1) + .with_range(0..1), + ) + .await + .unwrap()[0]; + + assert_eq!( + address_0, + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" + ); + assert_eq!( + address_1, + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" + ); + assert_eq!( + address_2, + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" + ); + + Ok(()) +} + +#[tokio::test] +async fn private_key_secret_manager_bs58() -> Result<()> { + let secret_manager = SecretManager::from(PrivateKeySecretManager::try_from_b58( + "BfnURR6WSXJA6RyBr3WqGU99UzrVbWk9GSQgJqKtTRxZ", + )?); + + let address = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) + .with_account_index(0) + .with_range(0..1), + ) + .await + .unwrap()[0]; + + assert_eq!( + address, + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" + ); + + Ok(()) +} diff --git a/sdk/tests/client/secret_manager.rs b/sdk/tests/client/secret_manager/stronghold.rs similarity index 77% rename from sdk/tests/client/secret_manager.rs rename to sdk/tests/client/secret_manager/stronghold.rs index 1147e2bd7d..0ff2515c72 100644 --- a/sdk/tests/client/secret_manager.rs +++ b/sdk/tests/client/secret_manager/stronghold.rs @@ -1,4 +1,4 @@ -// Copyright 2022 IOTA Stiftung +// Copyright 2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use iota_sdk::client::{ @@ -6,31 +6,7 @@ use iota_sdk::client::{ }; #[tokio::test] -async fn mnemonic_secret_manager_dto() -> Result<()> { - let dto = r#"{"mnemonic": "acoustic trophy damage hint search taste love bicycle foster cradle brown govern endless depend situate athlete pudding blame question genius transfer van random vast"}"#; - let secret_manager: SecretManager = dto.parse()?; - - let addresses = secret_manager - .generate_ed25519_addresses( - GetAddressesOptions::default() - .with_bech32_hrp(SHIMMER_TESTNET_BECH32_HRP) - .with_account_index(0) - .with_range(0..1), - ) - .await - .unwrap(); - - assert_eq!( - addresses[0], - "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a".to_string() - ); - - Ok(()) -} - -#[cfg(feature = "stronghold")] -#[tokio::test] -async fn stronghold_secret_manager_dto() -> Result<()> { +async fn stronghold_secret_manager() -> Result<()> { iota_stronghold::engine::snapshot::try_set_encrypt_work_factor(0).unwrap(); let dto = r#"{"stronghold": {"password": "some_hopefully_secure_password", "snapshotPath": "snapshot_test_dir/test.stronghold"}}"#; @@ -59,7 +35,7 @@ async fn stronghold_secret_manager_dto() -> Result<()> { assert_eq!( addresses[0], - "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a".to_string() + "rms1qzev36lk0gzld0k28fd2fauz26qqzh4hd4cwymlqlv96x7phjxcw6v3ea5a" ); // Calling store_mnemonic() twice should fail, because we would otherwise overwrite the stored entry @@ -74,7 +50,6 @@ async fn stronghold_secret_manager_dto() -> Result<()> { Ok(()) } -#[cfg(feature = "stronghold")] #[tokio::test] async fn stronghold_mnemonic_missing() -> Result<()> { iota_stronghold::engine::snapshot::try_set_encrypt_work_factor(0).unwrap();