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    /// Create a wallet from an existing mnemonic phrase in the specified language.
205    ///
206    /// # Arguments
207    ///
208    /// * `language` - Language for the mnemonic word list
209    /// * `phrase` - BIP39 mnemonic phrase
210    /// * `passphrase` - Optional BIP39 passphrase
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the mnemonic is invalid.
215    pub fn from_mnemonic_in(
216        language: Language,
217        phrase: &str,
218        passphrase: Option<&str>,
219    ) -> Result<Self, DeriveError> {
220        let mnemonic = Mnemonic::parse_in(language, phrase)?;
221        Ok(Self::from_parts(&mnemonic, language, passphrase))
222    }
223
224    /// Build a wallet from a validated mnemonic, deriving the seed.
225    fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
226        let passphrase_str = passphrase.unwrap_or("");
227        let seed_bytes = mnemonic.to_seed(passphrase_str);
228        Self {
229            mnemonic: Zeroizing::new(mnemonic.to_string()),
230            seed: Zeroizing::new(seed_bytes),
231            has_passphrase: passphrase.is_some(),
232            language,
233        }
234    }
235
236    /// Get the mnemonic phrase.
237    ///
238    /// **Security Warning**: Handle this value carefully as it can
239    /// reconstruct all derived keys.
240    #[inline]
241    #[must_use]
242    pub fn mnemonic(&self) -> &str {
243        &self.mnemonic
244    }
245
246    /// Get the 64-byte BIP-39 seed, still wrapped in [`Zeroizing`].
247    ///
248    /// **Gated on the `raw-seed` feature** (off by default). Preferred
249    /// entry points for key material are
250    /// [`derive_secp256k1`](Self::derive_secp256k1) and
251    /// [`derive_ed25519`](Self::derive_ed25519), which keep the seed inside
252    /// [`Wallet`].
253    ///
254    /// Callers that enable `raw-seed` must treat the returned reference as
255    /// highly sensitive: keep it borrowed or copy into another
256    /// [`Zeroizing`] container.
257    #[cfg(any(feature = "raw-seed", test))]
258    #[inline]
259    #[must_use]
260    pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
261        &self.seed
262    }
263
264    /// Derive a secp256k1 key pair at the given BIP-32 path.
265    ///
266    /// Preferred entry point for chains that derive secp256k1 keys (EVM,
267    /// BTC, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
268    /// Keeps the underlying seed encapsulated within [`Wallet`].
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the path is malformed or derivation fails.
273    #[cfg(feature = "bip32")]
274    #[inline]
275    pub fn derive_secp256k1(
276        &self,
277        path: &str,
278    ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
279        crate::bip32::DerivedSecp256k1Key::derive(&self.seed, path)
280    }
281
282    /// Derive an Ed25519 key pair at the given SLIP-10 path.
283    ///
284    /// Preferred entry point for chains that derive Ed25519 keys (Solana,
285    /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
286    /// [`Wallet`].
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the path is malformed or derivation fails.
291    #[cfg(feature = "slip10")]
292    #[inline]
293    pub fn derive_ed25519(
294        &self,
295        path: &str,
296    ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
297        crate::slip10::DerivedEd25519Key::derive_path(self.seed.as_slice(), path)
298    }
299
300    /// Check if a passphrase was supplied at construction time.
301    ///
302    /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
303    /// even if the passphrase string itself was empty. Callers relying on
304    /// "non-empty passphrase" semantics must check the passphrase string before
305    /// constructing the wallet.
306    #[must_use]
307    pub const fn has_passphrase(&self) -> bool {
308        self.has_passphrase
309    }
310
311    /// Get the language of the mnemonic.
312    #[inline]
313    #[must_use]
314    pub const fn language(&self) -> Language {
315        self.language
316    }
317
318    /// Get the word count of the mnemonic.
319    #[inline]
320    #[must_use]
321    pub fn word_count(&self) -> usize {
322        self.mnemonic.split_whitespace().count()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
331
332    #[cfg(feature = "rand")]
333    #[test]
334    fn test_generate_12_words() {
335        let wallet = Wallet::generate(12, None).unwrap();
336        assert_eq!(wallet.word_count(), 12);
337        assert!(!wallet.has_passphrase());
338    }
339
340    #[cfg(feature = "rand")]
341    #[test]
342    fn test_generate_24_words() {
343        let wallet = Wallet::generate(24, None).unwrap();
344        assert_eq!(wallet.word_count(), 24);
345    }
346
347    #[cfg(feature = "rand")]
348    #[test]
349    fn test_generate_with_passphrase() {
350        let wallet = Wallet::generate(12, Some("secret")).unwrap();
351        assert!(wallet.has_passphrase());
352    }
353
354    #[test]
355    fn test_invalid_entropy_length() {
356        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
357        let result = Wallet::from_entropy(&[0u8; 15], None);
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_from_entropy() {
363        // 16 bytes = 12 words
364        let entropy = [0u8; 16];
365        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
366        assert_eq!(wallet.word_count(), 12);
367    }
368
369    #[test]
370    fn test_from_mnemonic() {
371        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
372        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
373    }
374
375    #[test]
376    fn test_passphrase_changes_seed() {
377        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
378        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
379
380        // Same mnemonic with different passphrase should produce different seeds
381        assert_ne!(wallet1.seed(), wallet2.seed());
382    }
383
384    #[test]
385    fn test_deterministic_seed() {
386        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
387        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
388        assert_eq!(wallet1.seed(), wallet2.seed());
389    }
390
391    #[test]
392    fn kat_bip39_seed_vector() {
393        // BIP-39 reference: "abandon...about" with empty passphrase
394        // Verified against Python pbkdf2_hmac + iancoleman.io
395        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
396        assert_eq!(
397            hex::encode(wallet.seed()),
398            "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
399             9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
400        );
401    }
402
403    #[test]
404    fn debug_redacts_mnemonic_and_seed() {
405        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
406        let dbg = alloc::format!("{wallet:?}");
407        assert!(
408            dbg.contains("[REDACTED]"),
409            "expected redaction markers: {dbg}"
410        );
411        assert!(
412            !dbg.contains("abandon"),
413            "Debug must not leak mnemonic words: {dbg}"
414        );
415        // BIP-39 seed hex prefix for abandon…about
416        assert!(
417            !dbg.contains("5eb00bbddcf06908"),
418            "Debug must not leak seed bytes: {dbg}"
419        );
420    }
421
422    #[test]
423    fn kat_all_zero_entropy_produces_abandon_about() {
424        let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
425        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
426    }
427}