diff --git a/key-wallet-ffi/FFI_API.md b/key-wallet-ffi/FFI_API.md index a9870ae9f..c6ec21c43 100644 --- a/key-wallet-ffi/FFI_API.md +++ b/key-wallet-ffi/FFI_API.md @@ -113,7 +113,7 @@ Functions: 63 | `wallet_build_and_sign_transaction` | Build and sign a transaction using the wallet's managed info This is the... | transaction | | `wallet_check_transaction` | Check if a transaction belongs to the wallet using ManagedWalletInfo #... | transaction | | `wallet_create_from_mnemonic` | Create a new wallet from mnemonic (backward compatibility - single network) ... | wallet | -| `wallet_create_from_mnemonic_with_options` | Create a new wallet from mnemonic with options # Safety - `mnemonic` must... | wallet | +| `wallet_create_from_mnemonic_with_options` | Create a new wallet from mnemonic with options The mnemonic may be in any... | wallet | | `wallet_create_from_seed` | Create a new wallet from seed (backward compatibility) # Safety - `seed`... | wallet | | `wallet_create_from_seed_with_options` | Create a new wallet from seed with options # Safety - `seed` must be a... | wallet | | `wallet_create_random` | Create a new random wallet (backward compatibility) # Safety - `error`... | wallet | @@ -324,8 +324,8 @@ Functions: 6 | `mnemonic_free` | Free a mnemonic string # Safety - `mnemonic` must be a valid pointer... | mnemonic | | `mnemonic_generate` | Generate a new mnemonic with specified word count (12, 15, 18, 21, or 24) #... | mnemonic | | `mnemonic_generate_with_language` | Generate a new mnemonic with specified language and word count # Safety ... | mnemonic | -| `mnemonic_to_seed` | Convert mnemonic to seed with optional passphrase # Safety - `mnemonic`... | mnemonic | -| `mnemonic_validate` | Validate a mnemonic phrase # Safety - `mnemonic` must be a valid... | mnemonic | +| `mnemonic_to_seed` | Convert mnemonic to seed with optional passphrase The mnemonic may be in... | mnemonic | +| `mnemonic_validate` | Validate a mnemonic phrase in any supported BIP-39 language | mnemonic | | `mnemonic_word_count` | Get word count from mnemonic # Safety - `mnemonic` must be a valid... | mnemonic | ### Utility Functions @@ -1357,7 +1357,7 @@ wallet_create_from_mnemonic(mnemonic: *const c_char, network: FFINetwork, error: ``` **Description:** -Create a new wallet from mnemonic (backward compatibility - single network) # Safety - `mnemonic` must be a valid pointer to a null-terminated C string - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed +Create a new wallet from mnemonic (backward compatibility - single network) The mnemonic may be in any supported BIP-39 language (detected automatically). # Safety - `mnemonic` must be a valid pointer to a null-terminated C string - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed **Safety:** - `mnemonic` must be a valid pointer to a null-terminated C string - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed @@ -1373,7 +1373,7 @@ wallet_create_from_mnemonic_with_options(mnemonic: *const c_char, network: FFINe ``` **Description:** -Create a new wallet from mnemonic with options # Safety - `mnemonic` must be a valid pointer to a null-terminated C string - `account_options` must be a valid pointer to FFIWalletAccountCreationOptions or null - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed +Create a new wallet from mnemonic with options The mnemonic may be in any supported BIP-39 language (detected automatically). # Safety - `mnemonic` must be a valid pointer to a null-terminated C string - `account_options` must be a valid pointer to FFIWalletAccountCreationOptions or null - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed **Safety:** - `mnemonic` must be a valid pointer to a null-terminated C string - `account_options` must be a valid pointer to FFIWalletAccountCreationOptions or null - `error` must be a valid pointer to an FFIError structure - The caller must ensure all pointers remain valid for the duration of this call - The returned pointer must be freed with `wallet_free` when no longer needed @@ -2207,7 +2207,7 @@ account_derive_extended_private_key_from_mnemonic(account: *const FFIAccount, mn ``` **Description:** -Derive an extended private key from a mnemonic + optional passphrase at the given index. Returns an opaque FFIExtendedPrivKey pointer that must be freed with `extended_private_key_free`. # Safety - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError +Derive an extended private key from a mnemonic + optional passphrase at the given index. Returns an opaque FFIExtendedPrivKey pointer that must be freed with `extended_private_key_free`. Accepts a mnemonic in any supported BIP-39 language (detected automatically). # Safety - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError **Safety:** - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError @@ -2271,7 +2271,7 @@ account_derive_private_key_from_mnemonic(account: *const FFIAccount, mnemonic: * ``` **Description:** -Derive a private key from a mnemonic + optional passphrase at the given index. Returns an opaque FFIPrivateKey pointer that must be freed with `private_key_free`. # Safety - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError +Derive a private key from a mnemonic + optional passphrase at the given index. Returns an opaque FFIPrivateKey pointer that must be freed with `private_key_free`. Accepts a mnemonic in any supported BIP-39 language (detected automatically). # Safety - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError **Safety:** - `account` must be a valid pointer to an FFIAccount - `mnemonic` must be a valid, null-terminated C string - `passphrase` may be null; if not null, must be a valid C string - `error` must be a valid pointer to an FFIError @@ -4112,7 +4112,7 @@ mnemonic_to_seed(mnemonic: *const c_char, passphrase: *const c_char, seed_out: * ``` **Description:** -Convert mnemonic to seed with optional passphrase # Safety - `mnemonic` must be a valid null-terminated C string - `passphrase` must be a valid null-terminated C string or null - `seed_out` must be a valid pointer to a buffer of at least 64 bytes - `seed_len` must be a valid pointer to store the seed length - `error` must be a valid pointer to an FFIError +Convert mnemonic to seed with optional passphrase The mnemonic may be in any supported BIP-39 language (detected automatically). # Safety - `mnemonic` must be a valid null-terminated C string - `passphrase` must be a valid null-terminated C string or null - `seed_out` must be a valid pointer to a buffer of at least 64 bytes - `seed_len` must be a valid pointer to store the seed length - `error` must be a valid pointer to an FFIError **Safety:** - `mnemonic` must be a valid null-terminated C string - `passphrase` must be a valid null-terminated C string or null - `seed_out` must be a valid pointer to a buffer of at least 64 bytes - `seed_len` must be a valid pointer to store the seed length - `error` must be a valid pointer to an FFIError @@ -4128,7 +4128,7 @@ mnemonic_validate(mnemonic: *const c_char, error: *mut FFIError) -> bool ``` **Description:** -Validate a mnemonic phrase # Safety - `mnemonic` must be a valid null-terminated C string or null - `error` must be a valid pointer to an FFIError +Validate a mnemonic phrase in any supported BIP-39 language. Shares the parse path with `mnemonic_to_seed` and `wallet_create_from_mnemonic`, so a phrase this accepts is guaranteed to be accepted by those functions as well. # Safety - `mnemonic` must be a valid null-terminated C string or null - `error` must be a valid pointer to an FFIError **Safety:** - `mnemonic` must be a valid null-terminated C string or null - `error` must be a valid pointer to an FFIError diff --git a/key-wallet-ffi/src/account_derivation.rs b/key-wallet-ffi/src/account_derivation.rs index cabc9d35a..999aeb88e 100644 --- a/key-wallet-ffi/src/account_derivation.rs +++ b/key-wallet-ffi/src/account_derivation.rs @@ -10,6 +10,7 @@ use crate::keys::{FFIExtendedPrivKey, FFIPrivateKey}; use crate::{check_ptr, deref_ptr, unwrap_or_return}; use key_wallet::account::derivation::AccountDerivation; use key_wallet::account::AccountTrait; +use key_wallet::Mnemonic; use std::ffi::CString; use std::os::raw::{c_char, c_uint}; use std::ptr; @@ -97,7 +98,7 @@ pub unsafe extern "C" fn bls_account_derive_private_key_from_seed( /// it with `string_free`. /// /// Notes: -/// - Uses the English wordlist for parsing the mnemonic. +/// - Accepts a mnemonic in any supported BIP-39 language (detected automatically). /// - Chain-agnostic; may return an error for accounts with internal/external chains. /// /// # Safety @@ -123,11 +124,13 @@ pub unsafe extern "C" fn bls_account_derive_private_key_from_mnemonic( } else { Some(unwrap_or_return!(std::ffi::CStr::from_ptr(passphrase).to_str(), error)) }; + let mnemonic_lang = + unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error).language(); let sk = unwrap_or_return!( account.inner().derive_from_mnemonic_private_key_at( mnemonic_str, passphrase_str, - key_wallet::mnemonic::Language::English, + mnemonic_lang, index, ), error @@ -175,7 +178,7 @@ pub unsafe extern "C" fn eddsa_account_derive_private_key_from_seed( /// it with `string_free`. /// /// Notes: -/// - Uses the English wordlist for parsing the mnemonic. +/// - Accepts a mnemonic in any supported BIP-39 language (detected automatically). /// /// # Safety /// - `account` must be a valid, non-null pointer to an `FFIEdDSAAccount` (only when `eddsa` feature is enabled). @@ -200,11 +203,13 @@ pub unsafe extern "C" fn eddsa_account_derive_private_key_from_mnemonic( } else { Some(unwrap_or_return!(std::ffi::CStr::from_ptr(passphrase).to_str(), error)) }; + let mnemonic_lang = + unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error).language(); let sk = unwrap_or_return!( account.inner().derive_from_mnemonic_private_key_at( mnemonic_str, passphrase_str, - key_wallet::mnemonic::Language::English, + mnemonic_lang, index, ), error @@ -332,6 +337,8 @@ pub unsafe extern "C" fn account_derive_private_key_from_seed( /// Derive an extended private key from a mnemonic + optional passphrase at the given index. /// Returns an opaque FFIExtendedPrivKey pointer that must be freed with `extended_private_key_free`. /// +/// Accepts a mnemonic in any supported BIP-39 language (detected automatically). +/// /// # Safety /// - `account` must be a valid pointer to an FFIAccount /// - `mnemonic` must be a valid, null-terminated C string @@ -353,11 +360,13 @@ pub unsafe extern "C" fn account_derive_extended_private_key_from_mnemonic( } else { Some(unwrap_or_return!(std::ffi::CStr::from_ptr(passphrase).to_str(), error)) }; + let mnemonic_lang = + unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error).language(); let derived = unwrap_or_return!( account.inner().derive_from_mnemonic_extended_xpriv_at( mnemonic_str, passphrase_str, - key_wallet::mnemonic::Language::English, + mnemonic_lang, index, ), error @@ -368,6 +377,8 @@ pub unsafe extern "C" fn account_derive_extended_private_key_from_mnemonic( /// Derive a private key from a mnemonic + optional passphrase at the given index. /// Returns an opaque FFIPrivateKey pointer that must be freed with `private_key_free`. /// +/// Accepts a mnemonic in any supported BIP-39 language (detected automatically). +/// /// # Safety /// - `account` must be a valid pointer to an FFIAccount /// - `mnemonic` must be a valid, null-terminated C string @@ -389,11 +400,13 @@ pub unsafe extern "C" fn account_derive_private_key_from_mnemonic( } else { Some(unwrap_or_return!(std::ffi::CStr::from_ptr(passphrase).to_str(), error)) }; + let mnemonic_lang = + unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error).language(); let derived = unwrap_or_return!( account.inner().derive_from_mnemonic_extended_xpriv_at( mnemonic_str, passphrase_str, - key_wallet::mnemonic::Language::English, + mnemonic_lang, index, ), error diff --git a/key-wallet-ffi/src/account_derivation_tests.rs b/key-wallet-ffi/src/account_derivation_tests.rs index e0cd2b243..09790da5b 100644 --- a/key-wallet-ffi/src/account_derivation_tests.rs +++ b/key-wallet-ffi/src/account_derivation_tests.rs @@ -326,4 +326,159 @@ mod tests { wallet::wallet_free(wallet); } } + + /// French mnemonic for entropy 0c1e24e5917779d297e14d45f14e1a1a — contains + /// non-ASCII words, so an English-only parse rejects it. + const FRENCH_MNEMONIC: &str = "amour troupeau couteau brèche gustatif tenaille exécuter capuche dicter lagune jaune cogner"; + + /// The `*_derive_*_from_mnemonic` helpers must auto-detect the phrase's + /// language: for a French mnemonic they must derive the same keys as their + /// `*_from_seed` counterparts fed the seed of that same phrase. + #[test] + #[cfg(all(feature = "bls", feature = "eddsa"))] + fn test_provider_key_derivation_from_non_english_mnemonic() { + let mut error = FFIError::default(); + + let mnemonic = std::ffi::CString::new(FRENCH_MNEMONIC).unwrap(); + let passphrase = std::ffi::CString::new("").unwrap(); + + let wallet = unsafe { + wallet::wallet_create_from_mnemonic(mnemonic.as_ptr(), FFINetwork::Mainnet, &mut error) + }; + assert!(!wallet.is_null(), "French mnemonic must create a wallet"); + + let mut seed = [0u8; 64]; + let ok = unsafe { + crate::mnemonic::mnemonic_to_seed( + mnemonic.as_ptr(), + passphrase.as_ptr(), + seed.as_mut_ptr(), + &mut (seed.len()), + &mut error, + ) + }; + assert!(ok, "French mnemonic must derive a seed"); + + let collection = + unsafe { crate::account_collection::wallet_get_account_collection(wallet, &mut error) }; + assert!(!collection.is_null()); + + unsafe { + // BLS operator key 0: mnemonic-based and seed-based derivation agree. + let operator_account = + crate::account_collection::account_collection_get_provider_operator_keys(collection) + as *mut crate::account::FFIBLSAccount; + assert!(!operator_account.is_null()); + + let sk_seed = super::super::bls_account_derive_private_key_from_seed( + operator_account, + seed.as_ptr(), + seed.len(), + 0, + &mut error, + ); + assert!(!sk_seed.is_null(), "BLS from-seed failed: {:?}", error.code); + let sk_mnemonic = super::super::bls_account_derive_private_key_from_mnemonic( + operator_account, + mnemonic.as_ptr(), + passphrase.as_ptr(), + 0, + &mut error, + ); + assert!(!sk_mnemonic.is_null(), "BLS from-mnemonic failed: {:?}", error.code); + assert_eq!( + std::ffi::CStr::from_ptr(sk_seed).to_str().unwrap(), + std::ffi::CStr::from_ptr(sk_mnemonic).to_str().unwrap(), + "BLS keys from mnemonic and from its seed must match" + ); + crate::utils::string_free(sk_seed); + crate::utils::string_free(sk_mnemonic); + crate::account::bls_account_free(operator_account); + + // Ed25519 platform node key 0: same agreement. + let platform_account = + crate::account_collection::account_collection_get_provider_platform_keys(collection) + as *mut crate::account::FFIEdDSAAccount; + assert!(!platform_account.is_null()); + + let node_seed = super::super::eddsa_account_derive_private_key_from_seed( + platform_account, + seed.as_ptr(), + seed.len(), + 0, + &mut error, + ); + assert!(!node_seed.is_null(), "Ed25519 from-seed failed: {:?}", error.code); + let node_mnemonic = super::super::eddsa_account_derive_private_key_from_mnemonic( + platform_account, + mnemonic.as_ptr(), + passphrase.as_ptr(), + 0, + &mut error, + ); + assert!(!node_mnemonic.is_null(), "Ed25519 from-mnemonic failed: {:?}", error.code); + assert_eq!( + std::ffi::CStr::from_ptr(node_seed).to_str().unwrap(), + std::ffi::CStr::from_ptr(node_mnemonic).to_str().unwrap(), + "Ed25519 keys from mnemonic and from its seed must match" + ); + crate::utils::string_free(node_seed); + crate::utils::string_free(node_mnemonic); + crate::account::eddsa_account_free(platform_account); + + crate::account_collection::account_collection_free(collection); + wallet::wallet_free(wallet); + } + } + + /// For standard (internal/external-chain) accounts the secp helpers still + /// refuse — but with the chain error, not a language error: the French + /// phrase must get past the mnemonic parse. + #[test] + fn test_secp_mnemonic_helpers_parse_non_english() { + let mut error = FFIError::default(); + + let mnemonic = std::ffi::CString::new(FRENCH_MNEMONIC).unwrap(); + let passphrase = std::ffi::CString::new("").unwrap(); + + let wallet = unsafe { + wallet::wallet_create_from_mnemonic(mnemonic.as_ptr(), FFINetwork::Testnet, &mut error) + }; + assert!(!wallet.is_null()); + let account = unsafe { + crate::account::wallet_get_account(wallet, 0, FFIAccountKind::StandardBIP44).account + }; + assert!(!account.is_null()); + + let xpriv = unsafe { + super::super::account_derive_extended_private_key_from_mnemonic( + account, + mnemonic.as_ptr(), + passphrase.as_ptr(), + 0, + &mut error, + ) + }; + assert!(xpriv.is_null()); + // InvalidInput = the standard-account chain refusal. Before the fix + // this was InvalidMnemonic — the parse itself rejected the phrase. + assert_eq!(error.code, FFIErrorCode::InvalidInput); + + let priv_key = unsafe { + super::super::account_derive_private_key_from_mnemonic( + account, + mnemonic.as_ptr(), + passphrase.as_ptr(), + 0, + &mut error, + ) + }; + assert!(priv_key.is_null()); + assert_eq!(error.code, FFIErrorCode::InvalidInput); + + unsafe { + account_free(account); + wallet::wallet_free(wallet); + } + } } diff --git a/key-wallet-ffi/src/mnemonic.rs b/key-wallet-ffi/src/mnemonic.rs index 55da2c1a2..7c5a8ab91 100644 --- a/key-wallet-ffi/src/mnemonic.rs +++ b/key-wallet-ffi/src/mnemonic.rs @@ -149,7 +149,11 @@ pub unsafe extern "C" fn mnemonic_generate_with_language( unwrap_or_return!(CString::new(mnemonic.to_string()), error).into_raw() } -/// Validate a mnemonic phrase +/// Validate a mnemonic phrase in any supported BIP-39 language. +/// +/// Shares the parse path with `mnemonic_to_seed` and +/// `wallet_create_from_mnemonic`, so a phrase this accepts is guaranteed +/// to be accepted by those functions as well. /// /// # Safety /// @@ -160,36 +164,21 @@ pub unsafe extern "C" fn mnemonic_validate(mnemonic: *const c_char, error: *mut let mnemonic = deref_ptr!(mnemonic, error); let mnemonic_str = unwrap_or_return!(CStr::from_ptr(mnemonic).to_str(), error); - use key_wallet::mnemonic::Language; - - // Try validation against all supported languages - let languages = [ - Language::English, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::Portuguese, - Language::Spanish, - ]; - - for language in languages.iter() { - if Mnemonic::validate(mnemonic_str, *language) { - return true; - } + if Mnemonic::from_phrase_in_any_language(mnemonic_str).is_err() { + (*error).set( + FFIErrorCode::InvalidMnemonic, + "Invalid mnemonic: does not match any supported language", + ); + return false; } - (*error).set( - FFIErrorCode::InvalidMnemonic, - "Invalid mnemonic: does not match any supported language", - ); - false + true } /// Convert mnemonic to seed with optional passphrase /// +/// The mnemonic may be in any supported BIP-39 language (detected +/// automatically). +/// /// # Safety /// /// - `mnemonic` must be a valid null-terminated C string @@ -216,8 +205,7 @@ pub unsafe extern "C" fn mnemonic_to_seed( unwrap_or_return!(CStr::from_ptr(passphrase).to_str(), error) }; - use key_wallet::mnemonic::Language; - let m = unwrap_or_return!(Mnemonic::from_phrase(mnemonic_str, Language::English), error); + let m = unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error); let seed = m.to_seed(passphrase_str); let seed_bytes: &[u8] = seed.as_ref(); diff --git a/key-wallet-ffi/src/mnemonic_tests.rs b/key-wallet-ffi/src/mnemonic_tests.rs index 5a4dc6b1f..befa1beb9 100644 --- a/key-wallet-ffi/src/mnemonic_tests.rs +++ b/key-wallet-ffi/src/mnemonic_tests.rs @@ -690,4 +690,90 @@ mod tests { mnemonic::mnemonic_free(mnemonic); } } + + /// Entropy whose French encoding contains non-ASCII (accented) words; + /// shared by the multi-language tests below. + const ANY_LANGUAGE_TEST_ENTROPY: [u8; 16] = [ + 0x0c, 0x1e, 0x24, 0xe5, 0x91, 0x77, 0x79, 0xd2, 0x97, 0xe1, 0x4d, 0x45, 0xf1, 0x4e, 0x1a, + 0x1a, + ]; + + /// French phrase for `ANY_LANGUAGE_TEST_ENTROPY`, with its BIP-39 seed + /// computed independently of this codebase (python + /// `hashlib.pbkdf2_hmac('sha512', NFKD(phrase), b"mnemonic", 2048)`). + const FRENCH_REFERENCE_PHRASE: &str = "amour troupeau couteau brèche gustatif tenaille exécuter capuche dicter lagune jaune cogner"; + const FRENCH_REFERENCE_SEED_HEX: &str = "895debca7a86928a0c4cb5712aefd6d4cf4c7cfd23448ccd5418932e4f00c940089a3501f4f33eaf115f1a689d6c1e54b6bb6d5f40e4791234cb2ac87d62df68"; + + /// The invariant that regressed in Dash Wallet iOS 9.0.0: whatever + /// `mnemonic_validate` accepts, `mnemonic_to_seed` and + /// `wallet_create_from_mnemonic` must accept too — in every supported + /// language, not just English. + #[test] + fn test_validate_parse_symmetry_all_languages() { + use dash_network::ffi::FFINetwork; + use key_wallet::mnemonic::{Language, Mnemonic}; + + for lang in Language::ALL { + let phrase = Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, lang).unwrap().phrase(); + let c_phrase = CString::new(phrase).unwrap(); + + let mut error = FFIError::default(); + let error = &mut error as *mut FFIError; + + let is_valid = unsafe { mnemonic::mnemonic_validate(c_phrase.as_ptr(), error) }; + assert!(is_valid, "{lang:?}: validate must accept"); + assert_eq!(unsafe { (*error).code }, FFIErrorCode::Success); + + let mut seed = [0u8; 64]; + let mut seed_len: usize = 64; + let ok = unsafe { + mnemonic::mnemonic_to_seed( + c_phrase.as_ptr(), + ptr::null(), + seed.as_mut_ptr(), + &mut seed_len, + error, + ) + }; + assert!(ok, "{lang:?}: to_seed must accept a validated phrase"); + assert_eq!(seed_len, 64); + assert_eq!(unsafe { (*error).code }, FFIErrorCode::Success); + + let wallet = unsafe { + crate::wallet::wallet_create_from_mnemonic( + c_phrase.as_ptr(), + FFINetwork::Testnet, + error, + ) + }; + assert!(!wallet.is_null(), "{lang:?}: wallet creation must accept a validated phrase"); + assert_eq!(unsafe { (*error).code }, FFIErrorCode::Success); + unsafe { crate::wallet::wallet_free(wallet) }; + } + } + + /// Seed derivation for a non-English phrase must match the BIP-39 spec + /// (PBKDF2-HMAC-SHA512 over the NFKD phrase), not merely succeed — + /// pinned against an independently computed reference vector. + #[test] + fn test_mnemonic_to_seed_french_reference_vector() { + let c_phrase = CString::new(FRENCH_REFERENCE_PHRASE).unwrap(); + let mut error = FFIError::default(); + let error = &mut error as *mut FFIError; + + let mut seed = [0u8; 64]; + let mut seed_len: usize = 64; + let ok = unsafe { + mnemonic::mnemonic_to_seed( + c_phrase.as_ptr(), + ptr::null(), + seed.as_mut_ptr(), + &mut seed_len, + error, + ) + }; + assert!(ok, "French phrase must derive a seed"); + assert_eq!(seed_len, 64); + assert_eq!(hex::encode(seed), FRENCH_REFERENCE_SEED_HEX); + } } diff --git a/key-wallet-ffi/src/wallet.rs b/key-wallet-ffi/src/wallet.rs index 113cd7902..8354642ea 100644 --- a/key-wallet-ffi/src/wallet.rs +++ b/key-wallet-ffi/src/wallet.rs @@ -20,6 +20,9 @@ use key_wallet::Network; /// Create a new wallet from mnemonic with options /// +/// The mnemonic may be in any supported BIP-39 language (detected +/// automatically). +/// /// # Safety /// /// - `mnemonic` must be a valid pointer to a null-terminated C string @@ -34,12 +37,10 @@ pub unsafe extern "C" fn wallet_create_from_mnemonic_with_options( account_options: *const FFIWalletAccountCreationOptions, error: *mut FFIError, ) -> *mut FFIWallet { - use key_wallet::mnemonic::Language; - let mnemonic = deref_ptr!(mnemonic, error); let mnemonic_str = unwrap_or_return!(CStr::from_ptr(mnemonic).to_str(), error); - let mnemonic = unwrap_or_return!(Mnemonic::from_phrase(mnemonic_str, Language::English), error); + let mnemonic = unwrap_or_return!(Mnemonic::from_phrase_in_any_language(mnemonic_str), error); let network_rust: Network = network.into(); let creation_options = if account_options.is_null() { @@ -56,6 +57,9 @@ pub unsafe extern "C" fn wallet_create_from_mnemonic_with_options( /// Create a new wallet from mnemonic (backward compatibility - single network) /// +/// The mnemonic may be in any supported BIP-39 language (detected +/// automatically). +/// /// # Safety /// /// - `mnemonic` must be a valid pointer to a null-terminated C string diff --git a/key-wallet-ffi/src/wallet_tests.rs b/key-wallet-ffi/src/wallet_tests.rs index 922ec7ea3..e24096713 100644 --- a/key-wallet-ffi/src/wallet_tests.rs +++ b/key-wallet-ffi/src/wallet_tests.rs @@ -32,6 +32,34 @@ mod wallet_tests { } } + /// Non-English (validated) mnemonics must create wallets too — the parse + /// used to hardcode English and reject them. + #[test] + fn test_wallet_creation_from_non_english_mnemonic() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + let entropy: [u8; 16] = [ + 0x0c, 0x1e, 0x24, 0xe5, 0x91, 0x77, 0x79, 0xd2, 0x97, 0xe1, 0x4d, 0x45, 0xf1, 0x4e, + 0x1a, 0x1a, + ]; + for lang in [Language::French, Language::Spanish] { + let phrase = Mnemonic::from_entropy(&entropy, lang).unwrap().phrase(); + let c_phrase = CString::new(phrase).unwrap(); + + let mut error = FFIError::default(); + let error = &mut error as *mut FFIError; + + let wallet = unsafe { + wallet::wallet_create_from_mnemonic(c_phrase.as_ptr(), FFINetwork::Testnet, error) + }; + assert!(!wallet.is_null(), "{lang:?}: wallet creation must succeed"); + assert_eq!(unsafe { (*error).code }, FFIErrorCode::Success); + unsafe { + wallet::wallet_free(wallet); + } + } + } + #[test] fn test_wallet_creation_from_seed() { let mut error = FFIError::default(); diff --git a/key-wallet-manager/src/lib.rs b/key-wallet-manager/src/lib.rs index 46954b353..7cfe06aff 100644 --- a/key-wallet-manager/src/lib.rs +++ b/key-wallet-manager/src/lib.rs @@ -268,15 +268,16 @@ impl WalletManager { self.structural_revision += 1; } - /// Create a new wallet from mnemonic and add it to the manager - /// Returns the computed wallet ID + /// Create a new wallet from mnemonic and add it to the manager. + /// The mnemonic may be in any supported BIP-39 language (detected + /// automatically). Returns the computed wallet ID. pub fn create_wallet_from_mnemonic( &mut self, mnemonic: &str, birth_height: CoreBlockHeight, account_creation_options: key_wallet::wallet::initialization::WalletAccountCreationOptions, ) -> Result { - let mnemonic_obj = Mnemonic::from_phrase(mnemonic, key_wallet::mnemonic::Language::English) + let mnemonic_obj = Mnemonic::from_phrase_in_any_language(mnemonic) .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?; let wallet = Wallet::from_mnemonic(mnemonic_obj, self.network, account_creation_options) @@ -313,7 +314,7 @@ impl WalletManager { /// It supports downgrading to a public-key-only wallet for security purposes. /// /// # Arguments - /// * `mnemonic` - The mnemonic phrase + /// * `mnemonic` - The mnemonic phrase, in any supported BIP-39 language /// * `birth_height` - Birth height for wallet scanning (0 to sync from genesis) /// * `account_creation_options` - Which accounts to create initially /// * `downgrade_to_pubkey_wallet` - If true, creates a wallet without private keys @@ -338,7 +339,7 @@ impl WalletManager { ) -> Result<(Vec, WalletId), WalletError> { use zeroize::Zeroize; - let mnemonic_obj = Mnemonic::from_phrase(mnemonic, key_wallet::mnemonic::Language::English) + let mnemonic_obj = Mnemonic::from_phrase_in_any_language(mnemonic) .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?; let mut wallet = diff --git a/key-wallet-manager/tests/integration_test.rs b/key-wallet-manager/tests/integration_test.rs index 402f8f3c3..c0ac7a35c 100644 --- a/key-wallet-manager/tests/integration_test.rs +++ b/key-wallet-manager/tests/integration_test.rs @@ -40,6 +40,28 @@ fn test_wallet_manager_from_mnemonic() { assert_eq!(manager.monitor_revision(), 1); } +#[test] +fn test_wallet_manager_from_non_english_mnemonic() { + // The mnemonic parse used to hardcode English; a French phrase (with + // non-ASCII words) must create a wallet too, with language auto-detected, + // on both network configurations. + let mnemonic = Mnemonic::generate(12, Language::French).unwrap(); + for network in [Network::Mainnet, Network::Testnet] { + let mut manager = WalletManager::::new(network); + + let wallet_result = manager.create_wallet_from_mnemonic( + &mnemonic.to_string(), + 0, + WalletAccountCreationOptions::Default, + ); + assert!( + wallet_result.is_ok(), + "Failed to create French-mnemonic wallet on {network:?}: {wallet_result:?}" + ); + assert_eq!(manager.wallet_count(), 1); + } +} + #[test] fn test_account_management() { let mut manager = WalletManager::::new(Network::Testnet); diff --git a/key-wallet-manager/tests/test_serialized_wallets.rs b/key-wallet-manager/tests/test_serialized_wallets.rs index 1bc7742b9..c24cda524 100644 --- a/key-wallet-manager/tests/test_serialized_wallets.rs +++ b/key-wallet-manager/tests/test_serialized_wallets.rs @@ -75,4 +75,43 @@ mod tests { assert_eq!(imported.synced_height(), 49_999); assert_eq!(imported.last_processed_height(), 49_999); } + + /// A non-English mnemonic must survive the full serialize → import round + /// trip on both network configurations: creation parses the French phrase + /// (auto-detected language) and the import decodes the embedded mnemonic + /// from the serialized bytes. + #[test] + fn test_create_wallet_from_non_english_mnemonic_serialized_bytes() { + use key_wallet::mnemonic::Language; + use key_wallet::Mnemonic; + + let french = Mnemonic::generate(12, Language::French).unwrap().to_string(); + for network in [Network::Mainnet, Network::Testnet] { + let mut manager = WalletManager::::new(network); + + let result = manager.create_wallet_from_mnemonic_return_serialized_bytes( + &french, + 100_000, + WalletAccountCreationOptions::Default, + false, // Keep private keys so the serialized wallet embeds the mnemonic + false, + ); + assert!( + result.is_ok(), + "French wallet creation failed on {network:?}: {:?}", + result.err() + ); + let (bytes, wallet_id) = result.unwrap(); + assert!(!bytes.is_empty()); + + let mut manager2 = WalletManager::::new(network); + let import_result = manager2.import_wallet_from_bytes(&bytes, 50_000); + assert!( + import_result.is_ok(), + "French wallet import failed on {network:?}: {:?}", + import_result.err() + ); + assert_eq!(import_result.unwrap(), wallet_id); + } + } } diff --git a/key-wallet/src/mnemonic.rs b/key-wallet/src/mnemonic.rs index 0aeec9495..dd85645b7 100644 --- a/key-wallet/src/mnemonic.rs +++ b/key-wallet/src/mnemonic.rs @@ -48,7 +48,39 @@ impl From for bip39_crate::Language { } } +impl From for Language { + fn from(lang: bip39_crate::Language) -> Self { + match lang { + bip39_crate::Language::English => Language::English, + bip39_crate::Language::SimplifiedChinese => Language::ChineseSimplified, + bip39_crate::Language::TraditionalChinese => Language::ChineseTraditional, + bip39_crate::Language::Czech => Language::Czech, + bip39_crate::Language::French => Language::French, + bip39_crate::Language::Italian => Language::Italian, + bip39_crate::Language::Japanese => Language::Japanese, + bip39_crate::Language::Korean => Language::Korean, + bip39_crate::Language::Portuguese => Language::Portuguese, + bip39_crate::Language::Spanish => Language::Spanish, + } + } +} + impl Language { + /// Every supported wordlist language, in the deterministic order + /// any-language parsing and validation try them (English first). + pub const ALL: [Language; 10] = [ + Language::English, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Czech, + Language::French, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::Portuguese, + Language::Spanish, + ]; + /// Raw BIP-39 wordlist (2048 words) for this language. /// /// Low-level primitive for callers that need direct wordlist access — @@ -66,35 +98,19 @@ impl Language { /// [`Mnemonic::cleanup_phrase`]. const IDEO_SP: &str = "\u{3000}"; -/// All wordlist languages key-wallet supports; [`word_in_any_list`] and -/// [`phrase_is_valid_any`] check their union. -const ALL_LANGUAGES: [Language; 10] = [ - Language::English, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::Portuguese, - Language::Spanish, -]; - /// `true` if `word` is a member of *any* supported language's wordlist. /// Exact membership; caller pre-normalizes. fn word_in_any_list(word: &str) -> bool { - ALL_LANGUAGES.iter().any(|l| l.word_list().contains(&word)) + Language::ALL.iter().any(|l| l.word_list().contains(&word)) } /// `true` if the (already-normalized) phrase decodes (all words present + -/// valid checksum) in *some* supported language. Per-language loop, never -/// autodetect. [`Mnemonic::validate`] re-runs NFKD internally (idempotent on -/// an already-normalized phrase). Note this enforces the BIP-39 ≥12-word -/// floor; inert here — its only caller is [`Mnemonic::cleanup_phrase`]'s -/// early-return gate. +/// valid checksum) in *some* supported language — the boolean face of +/// [`Mnemonic::from_phrase_in_any_language`]. Note this enforces the BIP-39 +/// ≥12-word floor; inert here — its only caller is +/// [`Mnemonic::cleanup_phrase`]'s early-return gate. fn phrase_is_valid_any(normalized: &str) -> bool { - ALL_LANGUAGES.iter().any(|&l| Mnemonic::validate(normalized, l)) + Mnemonic::from_phrase_in_any_language(normalized).is_ok() } /// BIP39 Mnemonic phrase @@ -122,13 +138,10 @@ impl bincode::Decode for Mnemonic { decoder: &mut D, ) -> core::result::Result { let phrase: String = bincode::Decode::decode(decoder)?; - // Parse back from phrase - default to English - let inner = bip39_crate::Mnemonic::parse(&phrase).map_err(|e| { - bincode::error::DecodeError::OtherString(format!("Invalid mnemonic: {}", e)) - })?; - Ok(Self { - inner, - }) + // Same deterministic per-language walk as `from_phrase_in_any_language` + // (bip39's autodetect can fail with AmbiguousLanguages on shared words). + Mnemonic::from_phrase_in_any_language(&phrase) + .map_err(|e| bincode::error::DecodeError::OtherString(e.to_string())) } } @@ -138,12 +151,8 @@ impl<'de, C> bincode::BorrowDecode<'de, C> for Mnemonic { decoder: &mut D, ) -> core::result::Result { let phrase: String = bincode::BorrowDecode::borrow_decode(decoder)?; - let inner = bip39_crate::Mnemonic::parse(&phrase).map_err(|e| { - bincode::error::DecodeError::OtherString(format!("Invalid mnemonic: {}", e)) - })?; - Ok(Self { - inner, - }) + Mnemonic::from_phrase_in_any_language(&phrase) + .map_err(|e| bincode::error::DecodeError::OtherString(e.to_string())) } } @@ -200,6 +209,33 @@ impl Mnemonic { }) } + /// Create a mnemonic from a phrase, trying every supported language. + /// + /// Languages are tried in [`Language::ALL`] order (English first); the + /// first language in which the phrase fully parses (all words present + + /// valid checksum) wins. This deliberately avoids bip39's autodetecting + /// `Mnemonic::parse`, whose `language_of` fails with `AmbiguousLanguages` + /// when every word is shared across wordlists. First-match is + /// deterministic, and because the BIP-39 seed is PBKDF2 over the phrase + /// text itself (not the resolved language), a hypothetical cross-language + /// full-phrase collision could only affect [`Self::language`] reporting, + /// never the derived seed. + pub fn from_phrase_in_any_language(phrase: &str) -> Result { + for language in Language::ALL { + if let Ok(inner) = bip39_crate::Mnemonic::parse_in(language.into(), phrase) { + return Ok(Self { + inner, + }); + } + } + Err(Error::InvalidMnemonic("does not match any supported language".into())) + } + + /// The wordlist language this mnemonic was parsed or generated in. + pub fn language(&self) -> Language { + self.inner.language().into() + } + /// Get the mnemonic phrase as a string pub fn phrase(&self) -> String { self.inner.words().collect::>().join(" ") @@ -380,8 +416,7 @@ impl FromStr for Mnemonic { type Err = Error; fn from_str(s: &str) -> Result { - // Try English by default - Self::from_phrase(s, Language::English) + Self::from_phrase_in_any_language(s) } } @@ -897,7 +932,7 @@ mod tests { #[test] fn word_list_has_expected_shape() { // Every supported language exposes the full 2048-word BIP-39 list. - for lang in ALL_LANGUAGES { + for lang in Language::ALL { assert_eq!(lang.word_list().len(), 2048, "{lang:?} wordlist must be 2048 words"); } // Known English endpoints (BIP-39 English is the canonical reference). @@ -913,7 +948,7 @@ mod tests { fn every_language_word_validates_in_union() { // A representative word from each supported language is a member of the // all-language union; only English words are English-local. - for lang in ALL_LANGUAGES { + for lang in Language::ALL { let w = first_word(lang); assert!(word_in_any_list(&w), "{lang:?} word should be in the union"); } @@ -1085,4 +1120,97 @@ mod tests { .collect(); assert!(Mnemonic::cleanup_phrase(&nospace).contains(IDEO_SP)); } + + /// Entropy whose French encoding contains non-ASCII (accented) words — + /// the fixture the any-language and NFC tests below share. + const ANY_LANGUAGE_TEST_ENTROPY: [u8; 16] = [ + 0x0c, 0x1e, 0x24, 0xe5, 0x91, 0x77, 0x79, 0xd2, 0x97, 0xe1, 0x4d, 0x45, 0xf1, 0x4e, 0x1a, + 0x1a, + ]; + + #[test] + fn test_from_phrase_in_any_language_round_trip_all_languages() { + for lang in Language::ALL { + let phrase = Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, lang).unwrap().phrase(); + let parsed = Mnemonic::from_phrase_in_any_language(&phrase) + .unwrap_or_else(|e| panic!("{lang:?} phrase must parse: {e}")); + assert_eq!(parsed.language(), lang, "{lang:?} must be detected"); + assert_eq!(parsed.phrase(), phrase); + let direct = Mnemonic::from_phrase(&phrase, lang).unwrap(); + assert_eq!(parsed.to_seed(""), direct.to_seed(""), "{lang:?} seed must match"); + } + } + + #[test] + fn test_from_phrase_in_any_language_nfc_input_matches_nfkd() { + use unicode_normalization::UnicodeNormalization; + let mnemonic = + Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, Language::French).unwrap(); + // `phrase()` renders wordlist entries, which BIP-39 stores in NFKD. + let nfkd_phrase = mnemonic.phrase(); + let nfc_phrase: String = nfkd_phrase.nfc().collect(); + assert_ne!( + nfc_phrase, nfkd_phrase, + "fixture must contain decomposable accents to cover NFC input" + ); + let parsed = Mnemonic::from_phrase_in_any_language(&nfc_phrase).unwrap(); + assert_eq!(parsed.language(), Language::French); + assert_eq!(parsed.to_seed(""), mnemonic.to_seed("")); + } + + #[test] + fn test_from_phrase_in_any_language_rejects_invalid() { + let bad_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon"; + let gibberish = "definitely not a valid mnemonic in any supported wordlist language"; + for phrase in [bad_checksum, "", " ", gibberish] { + let err = Mnemonic::from_phrase_in_any_language(phrase).unwrap_err(); + assert!( + err.to_string().contains("does not match any supported language"), + "unexpected error for {phrase:?}: {err}" + ); + } + } + + #[test] + fn test_from_phrase_in_any_language_english_first() { + // The ordering contract: English is tried first, so English phrases + // keep parsing byte-identically to the pre-any-language behavior. + assert_eq!(Language::ALL[0], Language::English); + assert_eq!(Language::ALL.len(), 10); + let phrase = + Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, Language::English).unwrap().phrase(); + let parsed = Mnemonic::from_phrase_in_any_language(&phrase).unwrap(); + assert_eq!(parsed.language(), Language::English); + } + + #[test] + fn test_language_accessor_round_trips_all_languages() { + // Exercises every arm of the bip39->key-wallet Language conversion. + for lang in Language::ALL { + let m = Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, lang).unwrap(); + assert_eq!(m.language(), lang); + } + } + + #[test] + fn test_from_str_parses_any_language() { + let french = + Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, Language::French).unwrap().phrase(); + let parsed: Mnemonic = french.parse().unwrap(); + assert_eq!(parsed.language(), Language::French); + assert_eq!(parsed.phrase(), french); + } + + #[test] + #[cfg(feature = "bincode")] + fn test_bincode_roundtrip_french_mnemonic() { + let original = + Mnemonic::from_entropy(&ANY_LANGUAGE_TEST_ENTROPY, Language::French).unwrap(); + let bytes = bincode::encode_to_vec(&original, bincode::config::standard()).unwrap(); + let (decoded, _): (Mnemonic, usize) = + bincode::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + assert_eq!(decoded.phrase(), original.phrase()); + assert_eq!(decoded.to_seed(""), original.to_seed("")); + } }