Skip to main content

kobe_primitives/
wallet.rs

1//! Unified wallet type for multi-chain key derivation.
2
3use alloc::string::{String, ToString};
4
5use bip39::{Language, Mnemonic};
6use zeroize::Zeroizing;
7
8use crate::DeriveError;
9
10/// A unified HD wallet that can derive keys for multiple cryptocurrencies.
11///
12/// This wallet holds a BIP39 mnemonic and derives a seed that can be used
13/// to generate addresses for Bitcoin, Ethereum, and other coins following
14/// BIP32/44/49/84 standards.
15///
16/// # Passphrase Support
17///
18/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
19/// This provides an extra layer of security - the same mnemonic with different
20/// passphrases will produce completely different wallets.
21#[derive(Debug)]
22pub struct Wallet {
23    /// BIP39 mnemonic phrase.
24    mnemonic: Zeroizing<String>,
25    /// Seed derived from mnemonic + passphrase.
26    seed: Zeroizing<[u8; 64]>,
27    /// Whether a passphrase was used.
28    has_passphrase: bool,
29    /// Language of the mnemonic.
30    language: Language,
31}
32
33impl Wallet {
34    /// Generate a new wallet with a random mnemonic.
35    ///
36    /// # Arguments
37    ///
38    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
39    /// * `passphrase` - Optional BIP39 passphrase for additional security
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if the word count is invalid.
44    ///
45    /// # Note
46    ///
47    /// This function requires the `rand` feature to be enabled.
48    #[cfg(feature = "rand")]
49    pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, DeriveError> {
50        Self::generate_in(Language::English, word_count, passphrase)
51    }
52
53    /// Generate a new wallet with a random mnemonic in the specified language.
54    ///
55    /// # Arguments
56    ///
57    /// * `language` - Language for the mnemonic word list
58    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
59    /// * `passphrase` - Optional BIP39 passphrase for additional security
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the word count is invalid.
64    ///
65    /// # Note
66    ///
67    /// This function requires the `rand` feature to be enabled.
68    #[cfg(feature = "rand")]
69    pub fn generate_in(
70        language: Language,
71        word_count: usize,
72        passphrase: Option<&str>,
73    ) -> Result<Self, DeriveError> {
74        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
75            return Err(DeriveError::Input(alloc::format!(
76                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
77            )));
78        }
79
80        let mnemonic = Mnemonic::generate_in(language, word_count)?;
81        Ok(Self::from_parts(&mnemonic, language, passphrase))
82    }
83
84    /// Generate a new wallet with a custom random number generator.
85    ///
86    /// This is useful in `no_std` environments where you provide your own
87    /// cryptographically secure RNG instead of relying on the system RNG.
88    ///
89    /// # Arguments
90    ///
91    /// * `rng` - A cryptographically secure random number generator
92    /// * `language` - Language for the mnemonic word list
93    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
94    /// * `passphrase` - Optional BIP39 passphrase for additional security
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the word count is invalid.
99    ///
100    /// # Note
101    ///
102    /// This function requires the `rand_core` feature to be enabled.
103    #[cfg(feature = "rand_core")]
104    pub fn generate_in_with<R>(
105        rng: &mut R,
106        language: Language,
107        word_count: usize,
108        passphrase: Option<&str>,
109    ) -> Result<Self, DeriveError>
110    where
111        R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
112    {
113        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
114            return Err(DeriveError::Input(alloc::format!(
115                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
116            )));
117        }
118
119        let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
120        Ok(Self::from_parts(&mnemonic, language, passphrase))
121    }
122
123    /// Create a wallet from raw entropy bytes (English by default).
124    ///
125    /// This is useful in `no_std` environments where you provide your own entropy
126    /// source instead of relying on the system RNG.
127    ///
128    /// # Arguments
129    ///
130    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
131    /// * `passphrase` - Optional BIP39 passphrase for additional security
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the entropy length is invalid.
136    pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, DeriveError> {
137        Self::from_entropy_in(Language::English, entropy, passphrase)
138    }
139
140    /// Create a wallet from raw entropy bytes in the specified language.
141    ///
142    /// This is useful in `no_std` environments where you provide your own entropy
143    /// source instead of relying on the system RNG.
144    ///
145    /// # Arguments
146    ///
147    /// * `language` - Language for the mnemonic word list
148    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
149    /// * `passphrase` - Optional BIP39 passphrase for additional security
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the entropy length is invalid.
154    pub fn from_entropy_in(
155        language: Language,
156        entropy: &[u8],
157        passphrase: Option<&str>,
158    ) -> Result<Self, DeriveError> {
159        let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
160        Ok(Self::from_parts(&mnemonic, language, passphrase))
161    }
162
163    /// Create a wallet from an existing mnemonic phrase.
164    ///
165    /// The language will be automatically detected from the phrase.
166    ///
167    /// # Arguments
168    ///
169    /// * `phrase` - BIP39 mnemonic phrase
170    /// * `passphrase` - Optional BIP39 passphrase
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the mnemonic is invalid.
175    pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, DeriveError> {
176        let mnemonic: Mnemonic = phrase.parse()?;
177        let language = mnemonic.language();
178        Ok(Self::from_parts(&mnemonic, language, passphrase))
179    }
180
181    /// Create a wallet from an existing mnemonic phrase in the specified language.
182    ///
183    /// # Arguments
184    ///
185    /// * `language` - Language for the mnemonic word list
186    /// * `phrase` - BIP39 mnemonic phrase
187    /// * `passphrase` - Optional BIP39 passphrase
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if the mnemonic is invalid.
192    pub fn from_mnemonic_in(
193        language: Language,
194        phrase: &str,
195        passphrase: Option<&str>,
196    ) -> Result<Self, DeriveError> {
197        let mnemonic = Mnemonic::parse_in(language, phrase)?;
198        Ok(Self::from_parts(&mnemonic, language, passphrase))
199    }
200
201    /// Build a wallet from a validated mnemonic, deriving the seed.
202    fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
203        let passphrase_str = passphrase.unwrap_or("");
204        let seed_bytes = mnemonic.to_seed(passphrase_str);
205        Self {
206            mnemonic: Zeroizing::new(mnemonic.to_string()),
207            seed: Zeroizing::new(seed_bytes),
208            has_passphrase: passphrase.is_some(),
209            language,
210        }
211    }
212
213    /// Get the mnemonic phrase.
214    ///
215    /// **Security Warning**: Handle this value carefully as it can
216    /// reconstruct all derived keys.
217    #[inline]
218    #[must_use]
219    pub fn mnemonic(&self) -> &str {
220        &self.mnemonic
221    }
222
223    /// Get the 64-byte seed for key derivation, still wrapped in [`Zeroizing`].
224    ///
225    /// The returned reference makes the wallet's sensitivity explicit at the
226    /// type level: callers must keep the result borrowed or copy it into
227    /// another [`Zeroizing`] container to avoid leaking unsealed seed bytes
228    /// on the stack.
229    ///
230    /// Most chain derivers should prefer the feature-gated
231    /// [`derive_secp256k1`](Self::derive_secp256k1) and
232    /// [`derive_ed25519`](Self::derive_ed25519) shortcuts over reaching for
233    /// the raw seed.
234    #[inline]
235    #[must_use]
236    pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
237        &self.seed
238    }
239
240    /// Derive a secp256k1 key pair at the given BIP-32 path.
241    ///
242    /// Preferred entry point for chains that derive secp256k1 keys (EVM,
243    /// BTC fallback, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
244    /// Keeps the underlying seed encapsulated within [`Wallet`].
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the path is malformed or derivation fails.
249    #[cfg(feature = "bip32")]
250    #[inline]
251    pub fn derive_secp256k1(
252        &self,
253        path: &str,
254    ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
255        crate::bip32::DerivedSecp256k1Key::derive(self.seed(), path)
256    }
257
258    /// Derive an Ed25519 key pair at the given SLIP-10 path.
259    ///
260    /// Preferred entry point for chains that derive Ed25519 keys (Solana,
261    /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
262    /// [`Wallet`].
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the path is malformed or derivation fails.
267    #[cfg(feature = "slip10")]
268    #[inline]
269    pub fn derive_ed25519(
270        &self,
271        path: &str,
272    ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
273        crate::slip10::DerivedEd25519Key::derive_path(self.seed().as_slice(), path)
274    }
275
276    /// Check if a passphrase was supplied at construction time.
277    ///
278    /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
279    /// even if the passphrase string itself was empty. Callers relying on
280    /// "non-empty passphrase" semantics must check the passphrase string before
281    /// constructing the wallet.
282    #[must_use]
283    pub const fn has_passphrase(&self) -> bool {
284        self.has_passphrase
285    }
286
287    /// Get the language of the mnemonic.
288    #[inline]
289    #[must_use]
290    pub const fn language(&self) -> Language {
291        self.language
292    }
293
294    /// Get the word count of the mnemonic.
295    #[inline]
296    #[must_use]
297    pub fn word_count(&self) -> usize {
298        self.mnemonic.split_whitespace().count()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
307
308    #[cfg(feature = "rand")]
309    #[test]
310    fn test_generate_12_words() {
311        let wallet = Wallet::generate(12, None).unwrap();
312        assert_eq!(wallet.word_count(), 12);
313        assert!(!wallet.has_passphrase());
314    }
315
316    #[cfg(feature = "rand")]
317    #[test]
318    fn test_generate_24_words() {
319        let wallet = Wallet::generate(24, None).unwrap();
320        assert_eq!(wallet.word_count(), 24);
321    }
322
323    #[cfg(feature = "rand")]
324    #[test]
325    fn test_generate_with_passphrase() {
326        let wallet = Wallet::generate(12, Some("secret")).unwrap();
327        assert!(wallet.has_passphrase());
328    }
329
330    #[test]
331    fn test_invalid_entropy_length() {
332        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
333        let result = Wallet::from_entropy(&[0u8; 15], None);
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_from_entropy() {
339        // 16 bytes = 12 words
340        let entropy = [0u8; 16];
341        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
342        assert_eq!(wallet.word_count(), 12);
343    }
344
345    #[test]
346    fn test_from_mnemonic() {
347        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
348        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
349    }
350
351    #[test]
352    fn test_passphrase_changes_seed() {
353        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
354        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
355
356        // Same mnemonic with different passphrase should produce different seeds
357        assert_ne!(wallet1.seed(), wallet2.seed());
358    }
359
360    #[test]
361    fn test_deterministic_seed() {
362        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
363        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
364        assert_eq!(wallet1.seed(), wallet2.seed());
365    }
366
367    #[test]
368    fn kat_bip39_seed_vector() {
369        // BIP-39 reference: "abandon...about" with empty passphrase
370        // Verified against Python pbkdf2_hmac + iancoleman.io
371        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
372        assert_eq!(
373            hex::encode(wallet.seed()),
374            "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
375             9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
376        );
377    }
378
379    #[test]
380    fn kat_all_zero_entropy_produces_abandon_about() {
381        let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
382        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
383    }
384}