From 5c87fc12e20d29bb93df1a17d2f5aab47e0cda5b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 5 Aug 2026 14:55:45 +0200 Subject: [PATCH 1/2] Prevent ambiguous mnemonic entropy panics Valid Chinese mnemonics can use words shared by the Simplified and Traditional lists. Entropy recovery redetects their language and unwraps the ambiguity error, causing a panic for otherwise valid values. Recover entropy directly from the stored, validated word indices. Co-Authored-By: HAL 9000 --- src/lib.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index eb10006..9ba5e8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -596,11 +596,6 @@ impl Mnemonic { /// The return value is a byte array and the size. /// Use [Mnemonic::to_entropy] (needs `std`) to get a [`Vec`]. pub fn to_entropy_array(&self) -> ([u8; 33], usize) { - // We unwrap errors here because this method can only be called on - // values that were already previously validated. - - let language = Mnemonic::language_of_iter(self.words()).unwrap(); - // Preallocate enough space for the longest possible word list let mut entropy = [0; 33]; let mut cursor = 0; @@ -608,9 +603,7 @@ impl Mnemonic { let mut remainder = 0; let nb_words = self.word_count(); - for word in self.words() { - let idx = language.find_word(word).expect("invalid mnemonic"); - + for idx in self.word_indices() { remainder |= ((idx as u32) << (32 - 11)) >> offset; offset += 11; @@ -1052,6 +1045,28 @@ mod tests { assert_eq!(Mnemonic::from_entropy(&vec![b'x'; 36]), Err(Error::BadEntropyBitCount(288))); } + #[cfg(all(feature = "chinese-simplified", feature = "chinese-traditional"))] + #[test] + fn ambiguous_chinese_mnemonic_entropy_round_trip() { + let entropy = [0u8; 16]; + let languages = [Language::SimplifiedChinese, Language::TraditionalChinese]; + + for language in languages.iter() { + let mnemonic = Mnemonic::from_entropy_in(*language, &entropy).unwrap(); + let recovered = std::panic::catch_unwind(|| { + let (array, len) = mnemonic.to_entropy_array(); + assert_eq!(&array[..len], &entropy[..]); + assert_eq!(mnemonic.to_entropy(), entropy); + }); + + assert!( + recovered.is_ok(), + "valid {:?} mnemonic must recover entropy without panicking", + language, + ); + } + } + #[test] fn debug_does_not_leak_phrase() { let m = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); From de2bcb2ebbd202f93a0cc886600c619bd0b817b2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 5 Aug 2026 15:01:51 +0200 Subject: [PATCH 2/2] Preserve mnemonic language through serde Serde currently stores only the displayed phrase. A phrase shared by the Simplified and Traditional Chinese lists cannot be auto-detected during deserialization, so a valid serialized mnemonic cannot be restored. Add a versioned language tag for ambiguous phrases while retaining the legacy string representation and reader behavior elsewhere. Co-Authored-By: HAL 9000 --- Cargo-minimal.lock | 10 +++ Cargo.toml | 1 + src/internal_macros.rs | 59 --------------- src/lib.rs | 167 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 175 insertions(+), 62 deletions(-) delete mode 100644 src/internal_macros.rs diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index ad36527..00862e5 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -9,6 +9,7 @@ dependencies = [ "rand", "rand_core 0.4.2", "serde", + "serde_test", "unicode-normalization", "zeroize", ] @@ -131,6 +132,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_test" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a49e2f787c0fddfd5e758cbbd3cbf81c3a8d12ef091283c52d9e9b4d417d3c" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.195" diff --git a/Cargo.toml b/Cargo.toml index e0a6b91..83b00bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ unicode-normalization = { version = "0.1.22", default-features = false, optional # Enabling the "rand" feature by default to run the benches bip39 = { path = ".", features = ["rand"] } bitcoin_hashes = ">=0.12,<0.15" # enable default features for test +serde_test = "=1.0.117" [package.metadata.docs.rs] diff --git a/src/internal_macros.rs b/src/internal_macros.rs deleted file mode 100644 index 4d4ece1..0000000 --- a/src/internal_macros.rs +++ /dev/null @@ -1,59 +0,0 @@ -/// Implement serde serialization based on the -/// fmt::Display and std::FromStr traits. -macro_rules! serde_string_impl { - ($name:ident, $expecting:expr) => { - #[cfg(feature = "serde")] - impl<'de> $crate::serde::Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result<$name, D::Error> - where - D: $crate::serde::de::Deserializer<'de>, - { - use core::fmt::{self, Formatter}; - use core::str::FromStr; - use alloc::string::String; - - struct Visitor; - impl<'de> $crate::serde::de::Visitor<'de> for Visitor { - type Value = $name; - - fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { - formatter.write_str($expecting) - } - - fn visit_str(self, v: &str) -> Result - where - E: $crate::serde::de::Error, - { - $name::from_str(v).map_err(E::custom) - } - - fn visit_borrowed_str(self, v: &'de str) -> Result - where - E: $crate::serde::de::Error, - { - self.visit_str(v) - } - - fn visit_string(self, v: String) -> Result - where - E: $crate::serde::de::Error, - { - self.visit_str(&v) - } - } - - deserializer.deserialize_str(Visitor) - } - } - - #[cfg(feature = "serde")] - impl<'de> $crate::serde::Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: $crate::serde::Serializer, - { - serializer.collect_str(&self) - } - } - }; -} diff --git a/src/lib.rs b/src/lib.rs index 9ba5e8c..bb94b69 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,8 +71,6 @@ extern crate zeroize; #[cfg(feature = "zeroize")] use zeroize::{Zeroize, ZeroizeOnDrop}; -#[macro_use] -mod internal_macros; mod language; mod pbkdf2; @@ -200,6 +198,11 @@ impl From for usize { /// mnemonic from all the supported languages. (Languages have to be explicitly enabled using /// the Cargo features.) /// +/// With the `serde` feature, unambiguous mnemonics serialize as their phrase. Mnemonics whose +/// phrase matches multiple enabled languages serialize as +/// `bip39:v1::` so their language survives deserialization. Deserialization +/// accepts both representations. +/// /// Supported number of words are 12, 15, 18, 21, and 24. #[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] @@ -214,7 +217,132 @@ pub struct Mnemonic { #[cfg(feature = "zeroize")] impl zeroize::DefaultIsZeroes for Language {} -serde_string_impl!(Mnemonic, "a BIP-39 Mnemonic Code"); +#[cfg(feature = "serde")] +const SERDE_TAG_PREFIX: &str = "bip39:v1:"; + +#[cfg(feature = "serde")] +fn serde_language_name(language: Language) -> &'static str { + match language { + Language::English => "english", + #[cfg(feature = "chinese-simplified")] + Language::SimplifiedChinese => "simplified-chinese", + #[cfg(feature = "chinese-traditional")] + Language::TraditionalChinese => "traditional-chinese", + #[cfg(feature = "czech")] + Language::Czech => "czech", + #[cfg(feature = "french")] + Language::French => "french", + #[cfg(feature = "italian")] + Language::Italian => "italian", + #[cfg(feature = "japanese")] + Language::Japanese => "japanese", + #[cfg(feature = "korean")] + Language::Korean => "korean", + #[cfg(feature = "portuguese")] + Language::Portuguese => "portuguese", + #[cfg(feature = "spanish")] + Language::Spanish => "spanish", + } +} + +#[cfg(feature = "serde")] +fn serde_language_from_name(name: &str) -> Option { + match name { + "english" => Some(Language::English), + #[cfg(feature = "chinese-simplified")] + "simplified-chinese" => Some(Language::SimplifiedChinese), + #[cfg(feature = "chinese-traditional")] + "traditional-chinese" => Some(Language::TraditionalChinese), + #[cfg(feature = "czech")] + "czech" => Some(Language::Czech), + #[cfg(feature = "french")] + "french" => Some(Language::French), + #[cfg(feature = "italian")] + "italian" => Some(Language::Italian), + #[cfg(feature = "japanese")] + "japanese" => Some(Language::Japanese), + #[cfg(feature = "korean")] + "korean" => Some(Language::Korean), + #[cfg(feature = "portuguese")] + "portuguese" => Some(Language::Portuguese), + #[cfg(feature = "spanish")] + "spanish" => Some(Language::Spanish), + _ => None, + } +} + +#[cfg(feature = "serde")] +struct SerdeMnemonic<'a>(&'a Mnemonic); + +#[cfg(feature = "serde")] +impl fmt::Display for SerdeMnemonic<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(SERDE_TAG_PREFIX)?; + f.write_str(serde_language_name(self.0.language()))?; + f.write_str(":")?; + self.0.fmt(f) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Mnemonic { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if let Err(Error::AmbiguousLanguages(_)) = Mnemonic::language_of_iter(self.words()) { + serializer.collect_str(&SerdeMnemonic(self)) + } else { + serializer.collect_str(self) + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Mnemonic { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Mnemonic; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a BIP-39 Mnemonic Code") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if !value.starts_with(SERDE_TAG_PREFIX) { + return value.parse().map_err(E::custom); + } + + let tagged = &value[SERDE_TAG_PREFIX.len()..]; + let separator = tagged + .find(':') + .ok_or_else(|| E::custom("missing BIP-39 mnemonic language separator"))?; + let language = serde_language_from_name(&tagged[..separator]) + .ok_or_else(|| E::custom("unknown BIP-39 mnemonic language"))?; + let phrase = &tagged[separator + 1..]; + + #[cfg(feature = "unicode-normalization")] + { + Mnemonic::parse_in(language, phrase).map_err(E::custom) + } + #[cfg(not(feature = "unicode-normalization"))] + { + Mnemonic::parse_in_normalized(language, phrase).map_err(E::custom) + } + } + } + + deserializer.deserialize_str(Visitor) + } +} impl Mnemonic { /// Ensure the content of the [Cow] is normalized UTF8. @@ -1067,6 +1195,39 @@ mod tests { } } + #[cfg(all(feature = "serde", feature = "chinese-simplified", feature = "chinese-traditional"))] + #[test] + fn serde_preserves_ambiguous_mnemonic_language() { + use serde_test::{assert_tokens, Token}; + + let entropy = [0u8; 16]; + let english = Mnemonic::from_entropy_in(Language::English, &entropy).unwrap(); + assert_tokens( + &english, + &[Token::Str( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + )], + ); + + let cases = [ + ( + Language::SimplifiedChinese, + "bip39:v1:simplified-chinese:\ + 的 的 的 的 的 的 的 的 的 的 的 在", + ), + ( + Language::TraditionalChinese, + "bip39:v1:traditional-chinese:\ + 的 的 的 的 的 的 的 的 的 的 的 在", + ), + ]; + + for &(language, serialized) in &cases { + let mnemonic = Mnemonic::from_entropy_in(language, &entropy).unwrap(); + assert_tokens(&mnemonic, &[Token::Str(serialized)]); + } + } + #[test] fn debug_does_not_leak_phrase() { let m = Mnemonic::from_entropy(&[0u8; 16]).unwrap();