From bd4ff8a7fd0e2ca3707c95ee38aa7fbd14c351bb Mon Sep 17 00:00:00 2001 From: Harper Date: Sun, 3 Dec 2023 14:31:27 +0000 Subject: [PATCH] feat: introduce wallet crate for ed25519-bip32 key management (#342) Co-authored-by: Santiago Carmuega --- Cargo.toml | 1 + pallas-traverse/src/hashes.rs | 27 ++++- pallas-wallet/Cargo.toml | 19 +++ pallas-wallet/src/lib.rs | 213 ++++++++++++++++++++++++++++++++++ pallas/Cargo.toml | 3 +- pallas/src/lib.rs | 4 + 6 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 pallas-wallet/Cargo.toml create mode 100644 pallas-wallet/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 5cb25c28..cfa25e56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "pallas-traverse", "pallas-txbuilder", "pallas-utxorpc", + "pallas-wallet", "pallas", "examples/block-download", "examples/block-decode", diff --git a/pallas-traverse/src/hashes.rs b/pallas-traverse/src/hashes.rs index 3d2c46ff..d2c0cb4b 100644 --- a/pallas-traverse/src/hashes.rs +++ b/pallas-traverse/src/hashes.rs @@ -1,6 +1,9 @@ use crate::{ComputeHash, OriginalHash}; use pallas_codec::utils::KeepRaw; -use pallas_crypto::hash::{Hash, Hasher}; +use pallas_crypto::{ + hash::{Hash, Hasher}, + key::ed25519::PublicKey, +}; use pallas_primitives::{alonzo, babbage, byron, conway}; impl ComputeHash<32> for byron::EbbHead { @@ -168,6 +171,12 @@ impl OriginalHash<32> for KeepRaw<'_, conway::MintedTransactionBody<'_>> { } } +impl ComputeHash<28> for PublicKey { + fn compute_hash(&self) -> Hash<28> { + Hasher::<224>::hash(&Into::<[u8; PublicKey::SIZE]>::into(*self)) + } +} + #[cfg(test)] mod tests { use crate::{Era, MultiEraTx}; @@ -176,6 +185,7 @@ mod tests { use pallas_codec::utils::Int; use pallas_codec::{minicbor, utils::Bytes}; use pallas_crypto::hash::Hash; + use pallas_crypto::key::ed25519::PublicKey; use pallas_primitives::babbage::MintedDatumOption; use pallas_primitives::{alonzo, babbage, byron}; use std::str::FromStr; @@ -394,4 +404,19 @@ mod tests { } } } + + #[test] + fn test_public_key_hash() { + let key: [u8; 32] = + hex::decode("2354bc4e1ae230e3a9047b568848fdd4bccd8d9aa60e6d1426baa730908e662d") + .unwrap() + .try_into() + .unwrap(); + let pk = PublicKey::from(key); + + assert_eq!( + pk.compute_hash().to_vec(), + hex::decode("2b6b3949d380fea6cb1c1cf88490ea40b2c1ce87717df7869cb1c38e").unwrap() + ) + } } diff --git a/pallas-wallet/Cargo.toml b/pallas-wallet/Cargo.toml new file mode 100644 index 00000000..f19aa055 --- /dev/null +++ b/pallas-wallet/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pallas-wallet" +description = "Cardano wallet utilities such as key generation" +version = "0.20.0" +edition = "2021" +repository = "https://github.com/txpipe/pallas" +homepage = "https://github.com/txpipe/pallas" +license = "Apache-2.0" +readme = "README.md" +authors = ["Santiago Carmuega "] + +[dependencies] +thiserror = "1.0.49" +pallas-crypto = { version = "=0.20.0", path = "../pallas-crypto" } +ed25519-bip32 = "0.4.1" +rand = "0.8.5" +bip39 = { version = "2.0.0", features = ["rand_core"] } +cryptoxide = "0.4.4" +bech32 = "0.9.1" diff --git a/pallas-wallet/src/lib.rs b/pallas-wallet/src/lib.rs new file mode 100644 index 00000000..cd39e356 --- /dev/null +++ b/pallas-wallet/src/lib.rs @@ -0,0 +1,213 @@ +use bech32::{FromBase32, ToBase32}; +use bip39::{Language, Mnemonic}; +use cryptoxide::{hmac::Hmac, pbkdf2::pbkdf2, sha2::Sha512}; +use ed25519_bip32::{self, XPrv, XPub, XPRV_SIZE}; +use pallas_crypto::key::ed25519::{self}; +use rand::{CryptoRng, RngCore}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + /// Unexpected bech32 HRP prefix + #[error("Unexpected bech32 HRP prefix")] + InvalidBech32Hrp, + /// Unable to decode bech32 string + #[error("Unable to decode bech32: {0}")] + InvalidBech32(bech32::Error), + /// Decoded bech32 data of unexpected length + #[error("Decoded bech32 data of unexpected length")] + UnexpectedBech32Length, + /// Error relating to ed25519-bip32 private key + #[error("Error relating to ed25519-bip32 private key: {0}")] + Xprv(ed25519_bip32::PrivateKeyError), + /// Error relating to bip39 mnemonic + #[error("Error relating to bip39 mnemonic: {0}")] + Mnemonic(bip39::Error), + /// Error when attempting to derive ed25519-bip32 key + #[error("Error when attempting to derive ed25519-bip32 key: {0}")] + DerivationError(ed25519_bip32::DerivationError), +} + +/// ED25519-BIP32 HD Private Key +#[derive(Debug, PartialEq, Eq)] +pub struct Bip32PrivateKey(ed25519_bip32::XPrv); + +impl Bip32PrivateKey { + const BECH32_HRP: &'static str = "xprv"; + + pub fn generate(mut rng: T) -> Self { + let mut buf = [0u8; XPRV_SIZE]; + rng.fill_bytes(&mut buf); + let xprv = XPrv::normalize_bytes_force3rd(buf); + + Self(xprv) + } + + pub fn generate_with_mnemonic( + mut rng: T, + password: String, + ) -> (Self, Mnemonic) { + let mut buf = [0u8; 64]; + rng.fill_bytes(&mut buf); + + let bip39 = Mnemonic::generate_in_with(&mut rng, Language::English, 24).unwrap(); + + let entropy = bip39.clone().to_entropy(); + + let mut pbkdf2_result = [0; XPRV_SIZE]; + + const ITER: u32 = 4096; // TODO: BIP39 says 2048, CML uses 4096? + + let mut mac = Hmac::new(Sha512::new(), password.as_bytes()); + pbkdf2(&mut mac, &entropy, ITER, &mut pbkdf2_result); + + (Self(XPrv::normalize_bytes_force3rd(pbkdf2_result)), bip39) + } + + pub fn from_bytes(bytes: [u8; 96]) -> Result { + XPrv::from_bytes_verified(bytes) + .map(Self) + .map_err(Error::Xprv) + } + + pub fn as_bytes(&self) -> Vec { + self.0.as_ref().to_vec() + } + + pub fn from_bip39_mnenomic(mnemonic: String, password: String) -> Result { + let bip39 = Mnemonic::parse(mnemonic).map_err(Error::Mnemonic)?; + let entropy = bip39.to_entropy(); + + let mut pbkdf2_result = [0; XPRV_SIZE]; + + const ITER: u32 = 4096; // TODO: BIP39 says 2048, CML uses 4096? + + let mut mac = Hmac::new(Sha512::new(), password.as_bytes()); + pbkdf2(&mut mac, &entropy, ITER, &mut pbkdf2_result); + + Ok(Self(XPrv::normalize_bytes_force3rd(pbkdf2_result))) + } + + pub fn derive(&self, index: u32) -> Self { + Self(self.0.derive(ed25519_bip32::DerivationScheme::V2, index)) + } + + pub fn to_ed25519_privkey(&self) -> ed25519::SecretKeyExtended { + self.0.extended_secret_key().into() + } + + pub fn to_public(&self) -> Bip32PublicKey { + Bip32PublicKey(self.0.public()) + } + + pub fn chain_code(&self) -> [u8; 32] { + *self.0.chain_code() + } + + pub fn to_bech32(&self) -> String { + bech32::encode( + Self::BECH32_HRP, + self.as_bytes().to_base32(), + bech32::Variant::Bech32, + ) + .unwrap() + } + + pub fn from_bech32(bech32: String) -> Result { + let (hrp, data, _) = bech32::decode(&bech32).map_err(Error::InvalidBech32)?; + if hrp != Self::BECH32_HRP { + return Err(Error::InvalidBech32Hrp); + } else { + let data = Vec::::from_base32(&data).map_err(Error::InvalidBech32)?; + Self::from_bytes(data.try_into().map_err(|_| Error::UnexpectedBech32Length)?) + } + } +} + +/// ED25519-BIP32 HD Public Key +#[derive(Debug, PartialEq, Eq)] +pub struct Bip32PublicKey(ed25519_bip32::XPub); + +impl Bip32PublicKey { + const BECH32_HRP: &'static str = "xpub"; + + pub fn from_bytes(bytes: [u8; 64]) -> Self { + Self(XPub::from_bytes(bytes)) + } + + pub fn as_bytes(&self) -> Vec { + self.0.as_ref().to_vec() + } + + pub fn derive(&self, index: u32) -> Result { + self.0 + .derive(ed25519_bip32::DerivationScheme::V2, index) + .map(Self) + .map_err(Error::DerivationError) + } + + pub fn to_ed25519_pubkey(&self) -> ed25519::PublicKey { + self.0.public_key().into() + } + + pub fn chain_code(&self) -> [u8; 32] { + *self.0.chain_code() + } + + pub fn to_bech32(&self) -> String { + bech32::encode( + Self::BECH32_HRP, + self.as_bytes().to_base32(), + bech32::Variant::Bech32, + ) + .unwrap() + } + + pub fn from_bech32(bech32: String) -> Result { + let (hrp, data, _) = bech32::decode(&bech32).map_err(Error::InvalidBech32)?; + if hrp != Self::BECH32_HRP { + return Err(Error::InvalidBech32Hrp); + } else { + let data = Vec::::from_base32(&data).map_err(Error::InvalidBech32)?; + Ok(Self::from_bytes( + data.try_into().map_err(|_| Error::UnexpectedBech32Length)?, + )) + } + } +} + +#[cfg(test)] +mod test { + use rand::rngs::OsRng; + + use crate::{Bip32PrivateKey, Bip32PublicKey}; + + #[test] + fn mnemonic_roundtrip() { + let (xprv, mne) = Bip32PrivateKey::generate_with_mnemonic(OsRng, "".into()); + + let xprv_from_mne = + Bip32PrivateKey::from_bip39_mnenomic(mne.to_string(), "".into()).unwrap(); + + assert_eq!(xprv, xprv_from_mne) + } + + #[test] + fn bech32_roundtrip() { + let xprv = Bip32PrivateKey::generate(OsRng); + + let xprv_bech32 = xprv.to_bech32(); + + let decoded_xprv = Bip32PrivateKey::from_bech32(xprv_bech32).unwrap(); + + assert_eq!(xprv, decoded_xprv); + + let xpub = xprv.to_public(); + + let xpub_bech32 = xpub.to_bech32(); + + let decoded_xpub = Bip32PublicKey::from_bech32(xpub_bech32).unwrap(); + + assert_eq!(xpub, decoded_xpub) + } +} diff --git a/pallas/Cargo.toml b/pallas/Cargo.toml index c3a6d84f..d40b1d0c 100644 --- a/pallas/Cargo.toml +++ b/pallas/Cargo.toml @@ -21,7 +21,8 @@ pallas-codec = { version = "=0.20.0", path = "../pallas-codec/" } pallas-utxorpc = { version = "=0.20.0", path = "../pallas-utxorpc/" } pallas-configs = { version = "=0.20.0", path = "../pallas-configs/" } pallas-rolldb = { version = "=0.20.0", path = "../pallas-rolldb/", optional = true } +pallas-wallet = { version = "=0.20.0", path = "../pallas-wallet/", optional = true } pallas-txbuilder = { version = "=0.20.0", path = "../pallas-txbuilder/" } [features] -unstable = ["pallas-rolldb"] +unstable = ["pallas-rolldb", "pallas-wallet"] diff --git a/pallas/src/lib.rs b/pallas/src/lib.rs index a5f46df6..49805521 100644 --- a/pallas/src/lib.rs +++ b/pallas/src/lib.rs @@ -53,6 +53,10 @@ pub mod storage { #[cfg(feature = "unstable")] pub use pallas_applying as applying; +#[doc(inline)] +#[cfg(feature = "unstable")] +pub use pallas_wallet as wallet; + #[doc(inline)] #[cfg(feature = "unstable")] pub use pallas_txbuilder as txbuilder;