Skip to content

Commit

Permalink
feat: introduce wallet crate for ed25519-bip32 key management (#342)
Browse files Browse the repository at this point in the history
Co-authored-by: Santiago Carmuega <[email protected]>
  • Loading branch information
jmhrpr and scarmuega authored Dec 3, 2023
1 parent b13d3b6 commit bd4ff8a
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 2 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ members = [
"pallas-traverse",
"pallas-txbuilder",
"pallas-utxorpc",
"pallas-wallet",
"pallas",
"examples/block-download",
"examples/block-decode",
Expand Down
27 changes: 26 additions & 1 deletion pallas-traverse/src/hashes.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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()
)
}
}
19 changes: 19 additions & 0 deletions pallas-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]

[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"
213 changes: 213 additions & 0 deletions pallas-wallet/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<T: RngCore + CryptoRng>(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<T: RngCore + CryptoRng>(
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<Self, Error> {
XPrv::from_bytes_verified(bytes)
.map(Self)
.map_err(Error::Xprv)
}

pub fn as_bytes(&self) -> Vec<u8> {
self.0.as_ref().to_vec()
}

pub fn from_bip39_mnenomic(mnemonic: String, password: String) -> Result<Self, Error> {
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<Self, Error> {
let (hrp, data, _) = bech32::decode(&bech32).map_err(Error::InvalidBech32)?;
if hrp != Self::BECH32_HRP {
return Err(Error::InvalidBech32Hrp);

Check failure on line 119 in pallas-wallet/src/lib.rs

View workflow job for this annotation

GitHub Actions / Lints

unneeded `return` statement
} else {
let data = Vec::<u8>::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<u8> {
self.0.as_ref().to_vec()
}

pub fn derive(&self, index: u32) -> Result<Self, Error> {
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<Self, Error> {
let (hrp, data, _) = bech32::decode(&bech32).map_err(Error::InvalidBech32)?;
if hrp != Self::BECH32_HRP {
return Err(Error::InvalidBech32Hrp);

Check failure on line 169 in pallas-wallet/src/lib.rs

View workflow job for this annotation

GitHub Actions / Lints

unneeded `return` statement
} else {
let data = Vec::<u8>::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)
}
}
3 changes: 2 additions & 1 deletion pallas/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions pallas/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit bd4ff8a

Please sign in to comment.