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 BIP-39 mnemonic and a derived 64-byte seed used by
13/// [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] (and the chain
14/// derivers built on them). The raw seed is **not** part of the default
15/// public API; enable the `raw-seed` feature only if an advanced caller
16/// truly needs [`Self::seed`].
17///
18/// # Passphrase Support
19///
20/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
21/// This provides an extra layer of security - the same mnemonic with different
22/// passphrases will produce completely different wallets.
23pub struct Wallet {
24    /// BIP39 mnemonic phrase.
25    mnemonic: Zeroizing<String>,
26    /// Seed derived from mnemonic + passphrase.
27    ///
28    /// Read via [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] or the
29    /// feature-gated [`Self::seed`]. Marked `allow(dead_code)` so a minimal
30    /// `alloc`-only build (no bip32/slip10/raw-seed) still retains the seed
31    /// for future derive calls without a false-positive lint.
32    #[allow(
33        dead_code,
34        reason = "read by derive_* / raw-seed; retained when those features are off"
35    )]
36    seed: Zeroizing<[u8; 64]>,
37    /// Whether a passphrase was used.
38    has_passphrase: bool,
39    /// Language of the mnemonic.
40    language: Language,
41}
42
43impl core::fmt::Debug for Wallet {
44    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
45        // Never print mnemonic or seed — Zeroizing's Debug is not redacting.
46        f.debug_struct("Wallet")
47            .field("mnemonic", &"[REDACTED]")
48            .field("seed", &"[REDACTED]")
49            .field("has_passphrase", &self.has_passphrase)
50            .field("language", &self.language)
51            .field("word_count", &self.word_count())
52            .finish()
53    }
54}
55
56impl Wallet {
57    /// Generate a new wallet with a random mnemonic.
58    ///
59    /// # Arguments
60    ///
61    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
62    /// * `passphrase` - Optional BIP39 passphrase for additional security
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the word count is invalid.
67    ///
68    /// # Note
69    ///
70    /// This function requires the `rand` feature to be enabled.
71    #[cfg(feature = "rand")]
72    pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, DeriveError> {
73        Self::generate_in(Language::English, word_count, passphrase)
74    }
75
76    /// Generate a new wallet with a random mnemonic in the specified language.
77    ///
78    /// # Arguments
79    ///
80    /// * `language` - Language for the mnemonic word list
81    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
82    /// * `passphrase` - Optional BIP39 passphrase for additional security
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the word count is invalid.
87    ///
88    /// # Note
89    ///
90    /// This function requires the `rand` feature to be enabled.
91    #[cfg(feature = "rand")]
92    pub fn generate_in(
93        language: Language,
94        word_count: usize,
95        passphrase: Option<&str>,
96    ) -> Result<Self, DeriveError> {
97        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
98            return Err(DeriveError::Input(alloc::format!(
99                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
100            )));
101        }
102
103        let mnemonic = Mnemonic::generate_in(language, word_count)?;
104        Ok(Self::from_parts(&mnemonic, language, passphrase))
105    }
106
107    /// Generate a new wallet with a custom random number generator.
108    ///
109    /// This is useful in `no_std` environments where you provide your own
110    /// cryptographically secure RNG instead of relying on the system RNG.
111    ///
112    /// # Arguments
113    ///
114    /// * `rng` - A cryptographically secure random number generator
115    /// * `language` - Language for the mnemonic word list
116    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
117    /// * `passphrase` - Optional BIP39 passphrase for additional security
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the word count is invalid.
122    ///
123    /// # Note
124    ///
125    /// This function requires the `rand_core` feature to be enabled.
126    #[cfg(feature = "rand_core")]
127    pub fn generate_in_with<R>(
128        rng: &mut R,
129        language: Language,
130        word_count: usize,
131        passphrase: Option<&str>,
132    ) -> Result<Self, DeriveError>
133    where
134        R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
135    {
136        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
137            return Err(DeriveError::Input(alloc::format!(
138                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
139            )));
140        }
141
142        let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
143        Ok(Self::from_parts(&mnemonic, language, passphrase))
144    }
145
146    /// Create a wallet from raw entropy bytes (English by default).
147    ///
148    /// This is useful in `no_std` environments where you provide your own entropy
149    /// source instead of relying on the system RNG.
150    ///
151    /// # Arguments
152    ///
153    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
154    /// * `passphrase` - Optional BIP39 passphrase for additional security
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the entropy length is invalid.
159    pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, DeriveError> {
160        Self::from_entropy_in(Language::English, entropy, passphrase)
161    }
162
163    /// Create a wallet from raw entropy bytes in the specified language.
164    ///
165    /// This is useful in `no_std` environments where you provide your own entropy
166    /// source instead of relying on the system RNG.
167    ///
168    /// # Arguments
169    ///
170    /// * `language` - Language for the mnemonic word list
171    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
172    /// * `passphrase` - Optional BIP39 passphrase for additional security
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the entropy length is invalid.
177    pub fn from_entropy_in(
178        language: Language,
179        entropy: &[u8],
180        passphrase: Option<&str>,
181    ) -> Result<Self, DeriveError> {
182        let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
183        Ok(Self::from_parts(&mnemonic, language, passphrase))
184    }
185
186    /// Create a wallet from an existing mnemonic phrase.
187    ///
188    /// The language will be automatically detected from the phrase.
189    ///
190    /// # Arguments
191    ///
192    /// * `phrase` - BIP39 mnemonic phrase
193    /// * `passphrase` - Optional BIP39 passphrase
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the mnemonic is invalid.
198    pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, DeriveError> {
199        let mnemonic: Mnemonic = phrase.parse()?;
200        let language = mnemonic.language();
201        Ok(Self::from_parts(&mnemonic, language, passphrase))
202    }
203
204    /// Expand 4-letter BIP-39 English prefixes then import (same path as CLI `import`).
205    ///
206    /// Full words pass through [`mnemonic::expand`](crate::mnemonic::expand) unchanged.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if expansion or BIP-39 parse fails.
211    pub fn from_mnemonic_expanded(
212        phrase: &str,
213        passphrase: Option<&str>,
214    ) -> Result<Self, DeriveError> {
215        let expanded = crate::mnemonic::expand(phrase)?;
216        Self::from_mnemonic(&expanded, passphrase)
217    }
218
219    /// Create a wallet from an existing mnemonic phrase in the specified language.
220    ///
221    /// # Arguments
222    ///
223    /// * `language` - Language for the mnemonic word list
224    /// * `phrase` - BIP39 mnemonic phrase
225    /// * `passphrase` - Optional BIP39 passphrase
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if the mnemonic is invalid.
230    pub fn from_mnemonic_in(
231        language: Language,
232        phrase: &str,
233        passphrase: Option<&str>,
234    ) -> Result<Self, DeriveError> {
235        let mnemonic = Mnemonic::parse_in(language, phrase)?;
236        Ok(Self::from_parts(&mnemonic, language, passphrase))
237    }
238
239    /// Build a wallet from a validated mnemonic, deriving the seed.
240    fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
241        let passphrase_str = passphrase.unwrap_or("");
242        let seed_bytes = mnemonic.to_seed(passphrase_str);
243        Self {
244            mnemonic: Zeroizing::new(mnemonic.to_string()),
245            seed: Zeroizing::new(seed_bytes),
246            has_passphrase: passphrase.is_some(),
247            language,
248        }
249    }
250
251    /// Get the mnemonic phrase.
252    ///
253    /// **Security Warning**: Handle this value carefully as it can
254    /// reconstruct all derived keys.
255    #[inline]
256    #[must_use]
257    pub fn mnemonic(&self) -> &str {
258        &self.mnemonic
259    }
260
261    /// Get the 64-byte BIP-39 seed, still wrapped in [`Zeroizing`].
262    ///
263    /// **Gated on the `raw-seed` feature** (off by default). Preferred
264    /// entry points for key material are
265    /// [`derive_secp256k1`](Self::derive_secp256k1) and
266    /// [`derive_ed25519`](Self::derive_ed25519), which keep the seed inside
267    /// [`Wallet`].
268    ///
269    /// Callers that enable `raw-seed` must treat the returned reference as
270    /// highly sensitive: keep it borrowed or copy into another
271    /// [`Zeroizing`] container.
272    #[cfg(any(feature = "raw-seed", test))]
273    #[inline]
274    #[must_use]
275    pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
276        &self.seed
277    }
278
279    /// Derive a secp256k1 key pair at the given BIP-32 path.
280    ///
281    /// Preferred entry point for chains that derive secp256k1 keys (EVM,
282    /// BTC, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
283    /// Keeps the underlying seed encapsulated within [`Wallet`].
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if the path is malformed or derivation fails.
288    #[cfg(feature = "bip32")]
289    #[inline]
290    pub fn derive_secp256k1(
291        &self,
292        path: &str,
293    ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
294        crate::bip32::DerivedSecp256k1Key::derive(&self.seed, path)
295    }
296
297    /// Derive an Ed25519 key pair at the given SLIP-10 path.
298    ///
299    /// Preferred entry point for chains that derive Ed25519 keys (Solana,
300    /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
301    /// [`Wallet`].
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the path is malformed or derivation fails.
306    #[cfg(feature = "slip10")]
307    #[inline]
308    pub fn derive_ed25519(
309        &self,
310        path: &str,
311    ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
312        crate::slip10::DerivedEd25519Key::derive_path(self.seed.as_slice(), path)
313    }
314
315    /// Check if a passphrase was supplied at construction time.
316    ///
317    /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
318    /// even if the passphrase string itself was empty. Callers relying on
319    /// "non-empty passphrase" semantics must check the passphrase string before
320    /// constructing the wallet.
321    #[must_use]
322    pub const fn has_passphrase(&self) -> bool {
323        self.has_passphrase
324    }
325
326    /// Get the language of the mnemonic.
327    #[inline]
328    #[must_use]
329    pub const fn language(&self) -> Language {
330        self.language
331    }
332
333    /// Get the word count of the mnemonic.
334    #[inline]
335    #[must_use]
336    pub fn word_count(&self) -> usize {
337        self.mnemonic.split_whitespace().count()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
346
347    #[cfg(feature = "rand")]
348    #[test]
349    fn test_generate_12_words() {
350        let wallet = Wallet::generate(12, None).unwrap();
351        assert_eq!(wallet.word_count(), 12);
352        assert!(!wallet.has_passphrase());
353    }
354
355    #[cfg(feature = "rand")]
356    #[test]
357    fn test_generate_24_words() {
358        let wallet = Wallet::generate(24, None).unwrap();
359        assert_eq!(wallet.word_count(), 24);
360    }
361
362    #[cfg(feature = "rand")]
363    #[test]
364    fn test_generate_with_passphrase() {
365        let wallet = Wallet::generate(12, Some("secret")).unwrap();
366        assert!(wallet.has_passphrase());
367    }
368
369    #[test]
370    fn test_invalid_entropy_length() {
371        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
372        let result = Wallet::from_entropy(&[0u8; 15], None);
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_from_entropy() {
378        // 16 bytes = 12 words
379        let entropy = [0u8; 16];
380        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
381        assert_eq!(wallet.word_count(), 12);
382    }
383
384    #[test]
385    fn test_from_mnemonic() {
386        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
387        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
388    }
389
390    #[test]
391    fn test_passphrase_changes_seed() {
392        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
393        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
394
395        // Same mnemonic with different passphrase should produce different seeds
396        assert_ne!(wallet1.seed(), wallet2.seed());
397    }
398
399    #[test]
400    fn test_deterministic_seed() {
401        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
402        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
403        assert_eq!(wallet1.seed(), wallet2.seed());
404    }
405
406    #[test]
407    fn kat_bip39_seed_vector() {
408        // BIP-39 reference: "abandon...about" with empty passphrase
409        // Verified against Python pbkdf2_hmac + iancoleman.io
410        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
411        assert_eq!(
412            hex::encode(wallet.seed()),
413            "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
414             9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
415        );
416    }
417
418    #[test]
419    fn debug_redacts_mnemonic_and_seed() {
420        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
421        let dbg = alloc::format!("{wallet:?}");
422        assert!(
423            dbg.contains("[REDACTED]"),
424            "expected redaction markers: {dbg}"
425        );
426        assert!(
427            !dbg.contains("abandon"),
428            "Debug must not leak mnemonic words: {dbg}"
429        );
430        // BIP-39 seed hex prefix for abandon…about
431        assert!(
432            !dbg.contains("5eb00bbddcf06908"),
433            "Debug must not leak seed bytes: {dbg}"
434        );
435    }
436
437    #[test]
438    fn kat_all_zero_entropy_produces_abandon_about() {
439        let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
440        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
441    }
442}