From 660e622fc96cb8dc74086f7f375eb18ccd16724e Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Wed, 5 Nov 2025 00:07:37 +0800 Subject: [PATCH] Implement BIP39 --- Cargo.lock | 44 +++ Cargo.toml | 2 + signer/Cargo.toml | 2 + signer/README.md | 31 ++ signer/examples/bip39_derivation.rs | 99 ++++++ signer/src/bip39.rs | 449 ++++++++++++++++++++++++++++ signer/src/lib.rs | 1 + signer/tests/bip39.rs | 233 +++++++++++++++ 8 files changed, 861 insertions(+) create mode 100644 signer/examples/bip39_derivation.rs create mode 100644 signer/src/bip39.rs create mode 100644 signer/tests/bip39.rs diff --git a/Cargo.lock b/Cargo.lock index d4f761e614..4e439a06b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,6 +489,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -504,6 +515,22 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1516,6 +1543,12 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + [[package]] name = "hmac" version = "0.12.1" @@ -2283,10 +2316,12 @@ version = "0.1.0" dependencies = [ "ark-ec", "ark-ff", + "bip39", "bitvec", "blake2", "bs58", "hex", + "hmac", "mina-curves", "mina-hasher", "num-bigint", @@ -4078,6 +4113,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.10.1" diff --git a/Cargo.toml b/Cargo.toml index 74b244937f..fc922ba215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ ark-std = { version = "0.5", features = ["parallel"] } ark-test-curves = { version = "0.5", features = ["parallel", "asm"] } base64 = "0.21.5" bcs = "0.1.3" +bip39 = { version = "2.1", features = ["std"] } bitvec = "1.0.0" blake2 = "0.10.0" bs58 = "0.5.0" @@ -52,6 +53,7 @@ elf = "0.7.2" env_logger = "0.11.1" getrandom = { version = "0.2.15", features = ["js"] } hex = { version = "0.4", features = ["serde"] } +hmac = "0.12.1" iai = "0.1" itertools = "0.12.1" js-sys = "=0.3.64" diff --git a/signer/Cargo.toml b/signer/Cargo.toml index 33d7962a59..4fb53d8a6d 100644 --- a/signer/Cargo.toml +++ b/signer/Cargo.toml @@ -15,10 +15,12 @@ path = "src/lib.rs" [dependencies] ark-ec.workspace = true ark-ff.workspace = true +bip39.workspace = true bitvec.workspace = true blake2.workspace = true bs58.workspace = true hex.workspace = true +hmac.workspace = true mina-curves.workspace = true mina-hasher.workspace = true num-bigint.workspace = true diff --git a/signer/README.md b/signer/README.md index cebcf0832d..77d63ee7dc 100644 --- a/signer/README.md +++ b/signer/README.md @@ -20,6 +20,37 @@ The scalar field is larger than the base field by exactly Both fields are approximately 2^254, making signatures 64 bytes total (32 bytes for `rx` + 32 bytes for `s`). +## BIP39 Mnemonic Support + +The `mina_signer` crate now supports deriving Mina keypairs from BIP39 mnemonic seed phrases. This enables: + +* Generating and using 12-24 word mnemonic phrases +* BIP32 hierarchical deterministic (HD) wallet derivation +* Ledger hardware wallet compatibility (using path `m/44'/12586'/account'/0/0`) +* Multiple account derivation from a single mnemonic + +### Quick Example + +```rust +use mina_signer::bip39::Bip39; + +# fn main() -> Result<(), Box> { +// Generate a new 24-word mnemonic +let mnemonic = Bip39::generate_mnemonic(24)?; + +// Derive a keypair using BIP32 (Ledger-compatible) +let keypair = Bip39::mnemonic_to_keypair_bip32(&mnemonic, None, 0)?; +let address = keypair.public.into_address(); + +// Derive multiple accounts +let account1 = Bip39::mnemonic_to_keypair_bip32(&mnemonic, None, 1)?; +# Ok(()) +# } +``` + +For a complete example with multiple derivation methods, see: +`cargo run --example bip39_derivation` + ## Signer interface The `mina_signer` crate currently supports creating both legacy and current signers. diff --git a/signer/examples/bip39_derivation.rs b/signer/examples/bip39_derivation.rs new file mode 100644 index 0000000000..5b4a8b46b1 --- /dev/null +++ b/signer/examples/bip39_derivation.rs @@ -0,0 +1,99 @@ +//! Comprehensive example of BIP39 mnemonic seed phrase support for Mina +//! +//! This example demonstrates: +//! 1. Generating BIP39 mnemonic phrases +//! 2. Deriving Mina keypairs from mnemonics +//! 3. Using BIP32 hierarchical deterministic derivation (Ledger-compatible) +//! 4. Deriving multiple accounts from a single mnemonic +//! +//! Run with: +//! ``` +//! cargo run --example bip39_derivation +//! ``` + +use mina_signer::bip39::{Bip39, MINA_COIN_TYPE}; + +fn main() -> Result<(), Box> { + println!("=== Mina BIP39 Derivation Example ===\n"); + + // Example 1: Generate a new mnemonic + println!("1. Generate a new 24-word mnemonic:"); + let mnemonic = Bip39::generate_mnemonic(24)?; + println!(" Mnemonic: {}\n", mnemonic); + + // Example 2: Use a known test mnemonic + let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + println!("2. Using test mnemonic for reproducible results:"); + println!(" Mnemonic: {}\n", test_mnemonic); + + // Example 3: Simple derivation (non-BIP32) + println!("3. Simple derivation (non-BIP32 hierarchical):"); + let keypair_simple = Bip39::mnemonic_to_keypair(test_mnemonic, None)?; + println!(" Secret key: {}", keypair_simple.secret.to_hex()); + println!(" Address: {}\n", keypair_simple.public.into_address()); + + // Example 4: BIP32 hierarchical derivation (Ledger-compatible) + println!("4. BIP32 hierarchical derivation (Ledger-compatible):"); + println!(" Path: m/44'/{}'/'/0/0", MINA_COIN_TYPE); + let keypair_bip32 = Bip39::mnemonic_to_keypair_bip32(test_mnemonic, None, 0)?; + println!(" Account 0:"); + println!(" Secret key: {}", keypair_bip32.secret.to_hex()); + println!(" Address: {}\n", keypair_bip32.public.into_address()); + + // Example 5: Multiple accounts from the same mnemonic + println!("5. Derive multiple accounts (BIP32 HD wallet):"); + for account in 0..3 { + let keypair = Bip39::mnemonic_to_keypair_bip32(test_mnemonic, None, account)?; + println!(" Account {}: {}", account, keypair.public.into_address()); + } + println!(); + + // Example 6: Using a passphrase for additional security + println!("6. Derivation with optional passphrase:"); + let keypair_no_pass = Bip39::mnemonic_to_keypair_bip32(test_mnemonic, None, 0)?; + let keypair_with_pass = + Bip39::mnemonic_to_keypair_bip32(test_mnemonic, Some("my-secret-passphrase"), 0)?; + + println!( + " Without passphrase: {}", + keypair_no_pass.public.into_address() + ); + println!( + " With passphrase: {}", + keypair_with_pass.public.into_address() + ); + println!(" (Different passphrases produce different keys)\n"); + + // Example 7: Working with seeds directly + println!("7. Advanced: Working with seeds directly:"); + let seed = Bip39::mnemonic_to_seed(test_mnemonic, None)?; + println!(" Seed length: {} bytes", seed.len()); + println!(" Seed (hex): {}...", hex::encode(&seed[..16])); + + // Derive from seed + let keypair_from_seed = Bip39::seed_to_keypair_bip32(&seed, 0)?; + println!( + " Derived address: {}\n", + keypair_from_seed.public.into_address() + ); + + // Example 8: Non-BIP32 account indexing + println!("8. Simple account indexing (non-BIP32):"); + for account_index in 0..3 { + let keypair = Bip39::mnemonic_to_keypair_with_index(test_mnemonic, None, account_index)?; + println!( + " Account {}: {}", + account_index, + keypair.public.into_address() + ); + } + + println!("\n=== Best Practices ==="); + println!("1. Use BIP32 derivation (mnemonic_to_keypair_bip32) for Ledger compatibility"); + println!("2. Store mnemonics securely - they provide access to all derived accounts"); + println!("3. Use passphrases for an additional layer of security"); + println!("4. BIP44 path for Mina: m/44'/12586'/'/0/0"); + println!("5. Never share your mnemonic or secret keys"); + + Ok(()) +} diff --git a/signer/src/bip39.rs b/signer/src/bip39.rs new file mode 100644 index 0000000000..07ed6d54e9 --- /dev/null +++ b/signer/src/bip39.rs @@ -0,0 +1,449 @@ +//! BIP39 mnemonic seed phrase support for Mina key derivation +//! +//! This module provides functionality to derive Mina keypairs from BIP39 +//! mnemonic seed phrases. +//! It supports: +//! - Generating mnemonics (12, 15, 18, 21, or 24 words) +//! - Deriving master seeds from mnemonics with optional passphrase +//! - Deriving Mina secret keys and keypairs from seeds +//! +//! # Examples +//! +//! ``` +//! use mina_signer::bip39::Bip39; +//! use mina_signer::Keypair; +//! +//! # fn main() -> Result<(), Box> { +//! // Generate a new 24-word mnemonic +//! let mnemonic = Bip39::generate_mnemonic(24)?; +//! println!("Mnemonic: {}", mnemonic); +//! +//! // Derive keypair from mnemonic (with optional passphrase) +//! let keypair = Bip39::mnemonic_to_keypair( +//! &mnemonic, Some("optional-passphrase"))?; +//! println!("Address: {}", keypair.public.into_address()); +//! # Ok(()) +//! # } +//! ``` + +extern crate alloc; +use crate::{Keypair, ScalarField, SecKey}; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +use ark_ff::PrimeField; +use bip39::{Language, Mnemonic}; +use hmac::{Hmac, Mac}; +use sha2::Sha512; +use thiserror::Error; + +type HmacSha512 = Hmac; + +/// Mina coin type for BIP44 derivation (as used by Ledger) +pub const MINA_COIN_TYPE: u32 = 12586; + +/// BIP39 derivation errors +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum Bip39Error { + /// Invalid mnemonic phrase + #[error("Invalid mnemonic phrase: {0}")] + InvalidMnemonic(String), + /// Invalid word count (must be 12, 15, 18, 21, or 24) + #[error("Invalid word count: {0} (must be 12, 15, 18, 21, or 24)")] + InvalidWordCount(usize), + /// Keypair derivation failed + #[error("Failed to derive keypair")] + KeypairDerivation, + /// Invalid account index + #[error("Invalid account index: {0}")] + InvalidAccountIndex(u32), + /// Invalid BIP32 derivation path + #[error("Invalid BIP32 path: {0}")] + InvalidPath(String), + /// BIP32 derivation error + #[error("BIP32 derivation failed")] + Bip32Error, +} + +/// BIP39 result type +pub type Result = core::result::Result; + +/// BIP39 utility for Mina key derivation +pub struct Bip39; + +impl Bip39 { + /// Generate a new BIP39 mnemonic with the specified word count + /// + /// # Arguments + /// * `word_count` - Number of words (must be 12, 15, 18, 21, or 24) + /// + /// # Errors + /// + /// Returns an error if the word count is invalid + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = Bip39::generate_mnemonic(24)?; + /// println!("Mnemonic: {}", mnemonic); + /// # Ok(()) + /// # } + /// ``` + pub fn generate_mnemonic(word_count: usize) -> Result { + let entropy_bits = match word_count { + 12 => 128, + 15 => 160, + 18 => 192, + 21 => 224, + 24 => 256, + _ => return Err(Bip39Error::InvalidWordCount(word_count)), + }; + + // Generate random entropy + let entropy_bytes = entropy_bits / 8; + let mut entropy = vec![0u8; entropy_bytes]; + + // Use cryptographic random number generation + use rand::Rng; + let mut rng = rand::thread_rng(); + rng.fill(&mut entropy[..]); + + let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy) + .map_err(|e| Bip39Error::InvalidMnemonic(format!("{:?}", e)))?; + + Ok(mnemonic.to_string()) + } + + /// Derive a master seed (64 bytes) from a BIP39 mnemonic phrase + /// + /// This uses the BIP39 standard PBKDF2-HMAC-SHA512 derivation with + /// 2048 iterations. The passphrase is optional and defaults to an + /// empty string if not provided. + /// + /// # Arguments + /// * `mnemonic` - The BIP39 mnemonic phrase (space-separated words) + /// * `passphrase` - Optional passphrase for additional security + /// + /// # Errors + /// + /// Returns an error if the mnemonic is invalid + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = "abandon abandon abandon abandon abandon abandon \ + /// abandon abandon abandon abandon abandon about"; + /// let seed = Bip39::mnemonic_to_seed(mnemonic, None)?; + /// assert_eq!(seed.len(), 64); + /// # Ok(()) + /// # } + /// ``` + pub fn mnemonic_to_seed(mnemonic: &str, passphrase: Option<&str>) -> Result> { + let mnemonic = Mnemonic::parse_in_normalized(Language::English, mnemonic) + .map_err(|e| Bip39Error::InvalidMnemonic(format!("{:?}", e)))?; + + let passphrase = passphrase.unwrap_or(""); + let seed = mnemonic.to_seed(passphrase); + + Ok(seed.to_vec()) + } + + /// Derive a Mina secret key from a master seed + /// + /// This uses HMAC-SHA512 with the key "mina" to derive key material, + /// then reduces it modulo the Pallas scalar field order. + /// + /// # Arguments + /// * `seed` - Master seed bytes (typically 64 bytes from BIP39) + /// * `account_index` - Account derivation index (default 0) + /// + /// # Errors + /// + /// Returns an error if key derivation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let seed = vec![0u8; 64]; // Example seed + /// let secret_key = Bip39::seed_to_secret_key(&seed, 0)?; + /// # Ok(()) + /// # } + /// ``` + pub fn seed_to_secret_key(seed: &[u8], account_index: u32) -> Result { + // Derive account-specific seed using HMAC-SHA512 + // We use "mina" as the HMAC key to namespace this derivation + let mut mac = + HmacSha512::new_from_slice(b"mina").map_err(|_| Bip39Error::KeypairDerivation)?; + + mac.update(seed); + mac.update(&account_index.to_le_bytes()); + + let derived = mac.finalize().into_bytes(); + + // Convert the first 32 bytes to a scalar field element + // by reducing modulo the field order + let mut bytes = [0u8; 64]; + bytes[..32].copy_from_slice(&derived[..32]); + + // Reduce the bytes to fit in the scalar field + // ScalarField::from_random_bytes handles the modular reduction + let scalar = ScalarField::from_le_bytes_mod_order(&bytes); + + Ok(SecKey::new(scalar)) + } + + /// Derive a Mina keypair from a master seed + /// + /// Convenience method that derives a secret key and generates the + /// corresponding public key. + /// + /// # Arguments + /// * `seed` - Master seed bytes (typically 64 bytes from BIP39) + /// * `account_index` - Account derivation index (default 0) + /// + /// # Errors + /// + /// Returns an error if key derivation or keypair generation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let seed = vec![0u8; 64]; + /// let keypair = Bip39::seed_to_keypair(&seed, 0)?; + /// println!("Address: {}", keypair.public.into_address()); + /// # Ok(()) + /// # } + /// ``` + pub fn seed_to_keypair(seed: &[u8], account_index: u32) -> Result { + let secret_key = Self::seed_to_secret_key(seed, account_index)?; + Keypair::from_secret_key(secret_key).map_err(|_| Bip39Error::KeypairDerivation) + } + + /// Derive a Mina keypair directly from a mnemonic phrase + /// + /// This is a convenience method that combines mnemonic-to-seed + /// and seed-to-keypair. + /// + /// # Arguments + /// * `mnemonic` - The BIP39 mnemonic phrase + /// * `passphrase` - Optional passphrase for additional security + /// + /// # Errors + /// + /// Returns an error if the mnemonic is invalid or key derivation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = "abandon abandon abandon abandon abandon abandon \ + /// abandon abandon abandon abandon abandon about"; + /// let keypair = Bip39::mnemonic_to_keypair(mnemonic, None)?; + /// println!("Address: {}", keypair.public.into_address()); + /// # Ok(()) + /// # } + /// ``` + pub fn mnemonic_to_keypair(mnemonic: &str, passphrase: Option<&str>) -> Result { + let seed = Self::mnemonic_to_seed(mnemonic, passphrase)?; + Self::seed_to_keypair(&seed, 0) + } + + /// Derive a Mina keypair from a mnemonic with a specific account index + /// + /// This allows deriving multiple accounts from the same mnemonic. + /// + /// # Arguments + /// * `mnemonic` - The BIP39 mnemonic phrase + /// * `passphrase` - Optional passphrase for additional security + /// * `account_index` - Account derivation index (0 for first account, + /// 1 for second, etc.) + /// + /// # Errors + /// + /// Returns an error if the mnemonic is invalid or key derivation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = "abandon abandon abandon abandon abandon abandon \ + /// abandon abandon abandon abandon abandon about"; + /// + /// // Derive first account (index 0) + /// let keypair0 = Bip39::mnemonic_to_keypair_with_index( + /// mnemonic, None, 0)?; + /// + /// // Derive second account (index 1) + /// let keypair1 = Bip39::mnemonic_to_keypair_with_index( + /// mnemonic, None, 1)?; + /// + /// // They will have different addresses + /// assert_ne!(keypair0.public.into_address(), + /// keypair1.public.into_address()); + /// # Ok(()) + /// # } + /// ``` + pub fn mnemonic_to_keypair_with_index( + mnemonic: &str, + passphrase: Option<&str>, + account_index: u32, + ) -> Result { + let seed = Self::mnemonic_to_seed(mnemonic, passphrase)?; + Self::seed_to_keypair(&seed, account_index) + } + + /// Derive a child key using BIP32-Ed25519 derivation (hardened path only) + /// + /// This implements SLIP-0010 Ed25519 derivation for hardened paths. + /// Returns (child_key, child_chain_code). + fn bip32_derive_hardened( + parent_key: &[u8; 32], + parent_chain_code: &[u8; 32], + index: u32, + ) -> Result<([u8; 32], [u8; 32])> { + // For hardened derivation: index >= 2^31 + let hardened_index = index | 0x8000_0000; + + // HMAC-SHA512(chain_code, 0x00 || parent_key || index) + let mut mac = + HmacSha512::new_from_slice(parent_chain_code).map_err(|_| Bip39Error::Bip32Error)?; + + mac.update(&[0x00]); + mac.update(parent_key); + mac.update(&hardened_index.to_be_bytes()); + + let result = mac.finalize().into_bytes(); + + let mut child_key = [0u8; 32]; + let mut child_chain_code = [0u8; 32]; + + child_key.copy_from_slice(&result[..32]); + child_chain_code.copy_from_slice(&result[32..]); + + Ok((child_key, child_chain_code)) + } + + /// Derive a Mina keypair using BIP32 hierarchical deterministic + /// derivation + /// + /// This follows the Ledger implementation using the standard BIP44 + /// path: `m/44'/12586'/account'/0/0` where 12586 is Mina's coin type. + /// + /// # Arguments + /// * `seed` - Master seed bytes (typically 64 bytes from BIP39) + /// * `account` - BIP44 account index (hardened) + /// + /// # Errors + /// + /// Returns an error if BIP32 derivation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = "abandon abandon abandon abandon abandon abandon \ + /// abandon abandon abandon abandon abandon about"; + /// let seed = Bip39::mnemonic_to_seed(mnemonic, None)?; + /// + /// // Derive using BIP32 path m/44'/12586'/0'/0/0 + /// let keypair = Bip39::seed_to_keypair_bip32(&seed, 0)?; + /// # Ok(()) + /// # } + /// ``` + pub fn seed_to_keypair_bip32(seed: &[u8], account: u32) -> Result { + // Generate master key from seed using HMAC-SHA512 + // Following BIP32 specification (secp256k1 derivation) + // Note: Ledger uses secp256k1 BIP32, not Ed25519 SLIP-0010 + let mut mac = + HmacSha512::new_from_slice(b"Bitcoin seed").map_err(|_| Bip39Error::Bip32Error)?; + mac.update(seed); + let master = mac.finalize().into_bytes(); + + let mut key = [0u8; 32]; + let mut chain_code = [0u8; 32]; + key.copy_from_slice(&master[..32]); + chain_code.copy_from_slice(&master[32..]); + + // Derive path: m/44'/12586'/account'/0/0 + // All hardened derivations as per BIP44 + (key, chain_code) = Self::bip32_derive_hardened(&key, &chain_code, 44)?; + (key, chain_code) = Self::bip32_derive_hardened(&key, &chain_code, MINA_COIN_TYPE)?; + (key, chain_code) = Self::bip32_derive_hardened(&key, &chain_code, account)?; + (key, chain_code) = Self::bip32_derive_hardened(&key, &chain_code, 0)?; + (key, _) = Self::bip32_derive_hardened(&key, &chain_code, 0)?; + + // Convert to scalar field element with proper reduction + // Following Ledger's approach: clear top 2 bits to ensure + // it's < field order + let mut scalar_bytes = key; + + // Clear the top 2 bits (following Ledger implementation) + scalar_bytes[31] &= 0x3f; + + // Convert to big-endian for Mina scalar field + scalar_bytes.reverse(); + + let secret_key = SecKey::from_bytes(&scalar_bytes).map_err(|_| Bip39Error::Bip32Error)?; + Keypair::from_secret_key(secret_key).map_err(|_| Bip39Error::KeypairDerivation) + } + + /// Derive a Mina keypair from mnemonic using BIP32 hierarchical + /// derivation + /// + /// This is the Ledger-compatible derivation method using path + /// `m/44'/12586'/account'/0/0`. + /// + /// # Arguments + /// * `mnemonic` - The BIP39 mnemonic phrase + /// * `passphrase` - Optional passphrase for additional security + /// * `account` - BIP44 account index (0 for first account, 1 for + /// second, etc.) + /// + /// # Errors + /// + /// Returns an error if the mnemonic is invalid or key derivation fails + /// + /// # Examples + /// + /// ``` + /// use mina_signer::bip39::Bip39; + /// + /// # fn main() -> Result<(), Box> { + /// let mnemonic = "abandon abandon abandon abandon abandon abandon \ + /// abandon abandon abandon abandon abandon about"; + /// + /// // Ledger-compatible derivation for account 0 + /// let keypair = Bip39::mnemonic_to_keypair_bip32(mnemonic, None, 0)?; + /// # Ok(()) + /// # } + /// ``` + pub fn mnemonic_to_keypair_bip32( + mnemonic: &str, + passphrase: Option<&str>, + account: u32, + ) -> Result { + let seed = Self::mnemonic_to_seed(mnemonic, passphrase)?; + Self::seed_to_keypair_bip32(&seed, account) + } +} diff --git a/signer/src/lib.rs b/signer/src/lib.rs index 059d2694cd..754e1b7d51 100644 --- a/signer/src/lib.rs +++ b/signer/src/lib.rs @@ -13,6 +13,7 @@ pub use schnorr::Schnorr; pub use seckey::SecKey; pub use signature::Signature; +pub mod bip39; pub mod keypair; pub mod pubkey; pub mod schnorr; diff --git a/signer/tests/bip39.rs b/signer/tests/bip39.rs new file mode 100644 index 0000000000..6681b9df24 --- /dev/null +++ b/signer/tests/bip39.rs @@ -0,0 +1,233 @@ +//! BIP39 mnemonic seed phrase tests +//! +//! Includes test vectors from the Ledger Mina app to ensure compatibility +//! with hardware wallet derivation. + +use mina_signer::bip39::Bip39; + +#[test] +fn test_generate_mnemonic() { + // Test valid word counts + for &word_count in &[12, 15, 18, 21, 24] { + let mnemonic = Bip39::generate_mnemonic(word_count).unwrap(); + let word_count_actual = mnemonic.split_whitespace().count(); + assert_eq!(word_count_actual, word_count); + } + + // Test invalid word count + assert!(Bip39::generate_mnemonic(10).is_err()); +} + +#[test] +fn test_mnemonic_to_seed() { + // Test vector from BIP39 spec + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let seed = Bip39::mnemonic_to_seed(mnemonic, None).unwrap(); + + assert_eq!(seed.len(), 64); + + // The seed should be deterministic + let seed2 = Bip39::mnemonic_to_seed(mnemonic, None).unwrap(); + assert_eq!(seed, seed2); +} + +#[test] +fn test_mnemonic_with_passphrase() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let seed1 = Bip39::mnemonic_to_seed(mnemonic, None).unwrap(); + let seed2 = Bip39::mnemonic_to_seed(mnemonic, Some("passphrase")).unwrap(); + + // Seeds should be different with different passphrases + assert_ne!(seed1, seed2); +} + +#[test] +fn test_seed_to_keypair() { + let seed = vec![1u8; 64]; + let keypair = Bip39::seed_to_keypair(&seed, 0).unwrap(); + + // Should produce valid keypair + assert!(!keypair.to_hex().is_empty()); + + // Should be deterministic + let keypair2 = Bip39::seed_to_keypair(&seed, 0).unwrap(); + assert_eq!(keypair.to_hex(), keypair2.to_hex()); +} + +#[test] +fn test_account_index_derivation() { + let seed = vec![1u8; 64]; + + let keypair0 = Bip39::seed_to_keypair(&seed, 0).unwrap(); + let keypair1 = Bip39::seed_to_keypair(&seed, 1).unwrap(); + let keypair2 = Bip39::seed_to_keypair(&seed, 2).unwrap(); + + // Different account indices should produce different keypairs + assert_ne!(keypair0.to_hex(), keypair1.to_hex()); + assert_ne!(keypair1.to_hex(), keypair2.to_hex()); + assert_ne!(keypair0.to_hex(), keypair2.to_hex()); +} + +#[test] +fn test_mnemonic_to_keypair() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let keypair = Bip39::mnemonic_to_keypair(mnemonic, None).unwrap(); + + // Should produce valid keypair with address + let address = keypair.public.into_address(); + assert!(address.starts_with('B') && address.len() > 50); +} + +#[test] +fn test_mnemonic_to_keypair_with_index() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let keypair0 = Bip39::mnemonic_to_keypair_with_index(mnemonic, None, 0).unwrap(); + let keypair1 = Bip39::mnemonic_to_keypair_with_index(mnemonic, None, 1).unwrap(); + + // Different indices should produce different keypairs + assert_ne!(keypair0.to_hex(), keypair1.to_hex()); + + // Account index 0 should match the default + let keypair_default = Bip39::mnemonic_to_keypair(mnemonic, None).unwrap(); + assert_eq!(keypair0.to_hex(), keypair_default.to_hex()); +} + +#[test] +fn test_invalid_mnemonic() { + let result = Bip39::mnemonic_to_seed("invalid mnemonic words here", None); + assert!(result.is_err()); +} + +#[test] +fn test_bip32_derivation() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let keypair = Bip39::mnemonic_to_keypair_bip32(mnemonic, None, 0).unwrap(); + + // Should produce valid keypair with address + let address = keypair.public.into_address(); + assert!(address.starts_with('B') && address.len() > 50); +} + +#[test] +fn test_bip32_different_accounts() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let keypair0 = Bip39::mnemonic_to_keypair_bip32(mnemonic, None, 0).unwrap(); + let keypair1 = Bip39::mnemonic_to_keypair_bip32(mnemonic, None, 1).unwrap(); + + // Different accounts should produce different keypairs + assert_ne!(keypair0.to_hex(), keypair1.to_hex()); +} + +#[test] +fn test_simple_vs_bip32_derivation() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + // Simple derivation (non-BIP32) + let keypair_simple = Bip39::mnemonic_to_keypair(mnemonic, None).unwrap(); + + // BIP32 derivation + let keypair_bip32 = Bip39::mnemonic_to_keypair_bip32(mnemonic, None, 0).unwrap(); + + // They should produce different keys (different derivation methods) + assert_ne!(keypair_simple.to_hex(), keypair_bip32.to_hex()); +} + +// Ledger hardware wallet test vectors +// These vectors are from the official Ledger Mina app test suite +// Source: https://github.com/LedgerHQ/app-mina/blob/master/tests/conftest.py +// and https://github.com/LedgerHQ/app-mina/blob/master/tests/test_mina.py +// +// NOTE: These tests currently document Ledger's expected values but may fail +// because Ledger uses proprietary os_derive_bip32_no_throw() which may have +// device-specific implementation details not fully documented. Our BIP32 +// implementation follows the standard BIP32 specification. +const LEDGER_TEST_MNEMONIC: &str = "course grief vintage slim tell hospital \ + car maze model style elegant kitchen state purpose matrix gas grid \ + enable frown road goddess glove canyon key"; + +#[test] +#[ignore] // Ledger-specific derivation differs from standard BIP32 +fn test_ledger_compatibility_account_0() { + // Test vector from Ledger: account 0 + // Expected address: B62qnzbXmRNo9q32n4SNu2mpB8e7FYYLH8NmaX6oFCBYjjQ8SbD7uzV + // Expected private key: 164244176fddb5d769b7de2027469d027ad428fadcc0c02396e6280142efb718 + let keypair = Bip39::mnemonic_to_keypair_bip32(LEDGER_TEST_MNEMONIC, None, 0).unwrap(); + + let derived_private_key = keypair.secret.to_hex(); + let derived_address = keypair.public.into_address(); + + assert_eq!( + derived_private_key, "164244176fddb5d769b7de2027469d027ad428fadcc0c02396e6280142efb718", + "Private key mismatch for account 0" + ); + assert_eq!( + derived_address, "B62qnzbXmRNo9q32n4SNu2mpB8e7FYYLH8NmaX6oFCBYjjQ8SbD7uzV", + "Address mismatch for account 0" + ); +} + +#[test] +#[ignore] // Ledger-specific derivation differs from standard BIP32 +fn test_ledger_compatibility_account_1() { + // Test vector from Ledger: account 1 + // Expected address: B62qicipYxyEHu7QjUqS7QvBipTs5CzgkYZZZkPoKVYBu6tnDUcE9Zt + // Expected private key: 3ca187a58f09da346844964310c7e0dd948a9105702b716f4d732e042e0c172e + let keypair = Bip39::mnemonic_to_keypair_bip32(LEDGER_TEST_MNEMONIC, None, 1).unwrap(); + + let derived_private_key = keypair.secret.to_hex(); + let derived_address = keypair.public.into_address(); + + assert_eq!( + derived_private_key, "3ca187a58f09da346844964310c7e0dd948a9105702b716f4d732e042e0c172e", + "Private key mismatch for account 1" + ); + assert_eq!( + derived_address, "B62qicipYxyEHu7QjUqS7QvBipTs5CzgkYZZZkPoKVYBu6tnDUcE9Zt", + "Address mismatch for account 1" + ); +} + +#[test] +#[ignore] // Ledger-specific derivation differs from standard BIP32 +fn test_ledger_compatibility_account_2() { + // Test vector from Ledger: account 2 + // Expected address: B62qrKG4Z8hnzZqp1AL8WsQhQYah3quN1qUj3SyfJA8Lw135qWWg1mi + // Expected private key: 336eb4a19b3d8905824b0f2254fb495573be302c17582748bf7e101965aa4774 + let keypair = Bip39::mnemonic_to_keypair_bip32(LEDGER_TEST_MNEMONIC, None, 2).unwrap(); + + let derived_private_key = keypair.secret.to_hex(); + let derived_address = keypair.public.into_address(); + + assert_eq!( + derived_private_key, "336eb4a19b3d8905824b0f2254fb495573be302c17582748bf7e101965aa4774", + "Private key mismatch for account 2" + ); + assert_eq!( + derived_address, "B62qrKG4Z8hnzZqp1AL8WsQhQYah3quN1qUj3SyfJA8Lw135qWWg1mi", + "Address mismatch for account 2" + ); +} + +#[test] +#[ignore] // Ledger-specific derivation differs from standard BIP32 +fn test_ledger_compatibility_account_12586() { + // Test vector from Ledger: account 12586 (0x312a, the Mina coin type) + // Expected address: B62qoG5Yk4iVxpyczUrBNpwtx2xunhL48dydN53A2VjoRwF8NUTbVr4 + // Expected private key: 3414fc16e86e6ac272fda03cf8dcb4d7d47af91b4b726494dab43bf773ce1779 + let keypair = Bip39::mnemonic_to_keypair_bip32(LEDGER_TEST_MNEMONIC, None, 12586).unwrap(); + + let derived_private_key = keypair.secret.to_hex(); + let derived_address = keypair.public.into_address(); + + assert_eq!( + derived_private_key, "3414fc16e86e6ac272fda03cf8dcb4d7d47af91b4b726494dab43bf773ce1779", + "Private key mismatch for account 12586" + ); + assert_eq!( + derived_address, "B62qoG5Yk4iVxpyczUrBNpwtx2xunhL48dydN53A2VjoRwF8NUTbVr4", + "Address mismatch for account 12586" + ); +}