kobe-primitives 3.4.0

Multi-chain HD wallet derivation library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! Unified wallet type for multi-chain key derivation.

use alloc::string::{String, ToString};

use bip39::{Language, Mnemonic};
use zeroize::Zeroizing;

use crate::DeriveError;

/// A unified HD wallet that can derive keys for multiple cryptocurrencies.
///
/// This wallet holds a BIP-39 mnemonic and a derived 64-byte seed used by
/// [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] (and the chain
/// derivers built on them). The raw seed is **not** part of the default
/// public API; enable the `raw-seed` feature only if an advanced caller
/// truly needs [`Self::seed`].
///
/// # Passphrase Support
///
/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
/// This provides an extra layer of security - the same mnemonic with different
/// passphrases will produce completely different wallets.
pub struct Wallet {
    /// BIP39 mnemonic phrase.
    mnemonic: Zeroizing<String>,
    /// Seed derived from mnemonic + passphrase.
    ///
    /// Read via [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] or the
    /// feature-gated [`Self::seed`]. Marked `allow(dead_code)` so a minimal
    /// `alloc`-only build (no bip32/slip10/raw-seed) still retains the seed
    /// for future derive calls without a false-positive lint.
    #[allow(
        dead_code,
        reason = "read by derive_* / raw-seed; retained when those features are off"
    )]
    seed: Zeroizing<[u8; 64]>,
    /// Whether a passphrase was used.
    has_passphrase: bool,
    /// Language of the mnemonic.
    language: Language,
}

impl core::fmt::Debug for Wallet {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        // Never print mnemonic or seed — Zeroizing's Debug is not redacting.
        f.debug_struct("Wallet")
            .field("mnemonic", &"[REDACTED]")
            .field("seed", &"[REDACTED]")
            .field("has_passphrase", &self.has_passphrase)
            .field("language", &self.language)
            .field("word_count", &self.word_count())
            .finish()
    }
}

impl Wallet {
    /// Generate a new wallet with a random mnemonic.
    ///
    /// # Arguments
    ///
    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
    /// * `passphrase` - Optional BIP39 passphrase for additional security
    ///
    /// # Errors
    ///
    /// Returns an error if the word count is invalid.
    ///
    /// # Note
    ///
    /// This function requires the `rand` feature to be enabled.
    #[cfg(feature = "rand")]
    pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, DeriveError> {
        Self::generate_in(Language::English, word_count, passphrase)
    }

    /// Generate a new wallet with a random mnemonic in the specified language.
    ///
    /// # Arguments
    ///
    /// * `language` - Language for the mnemonic word list
    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
    /// * `passphrase` - Optional BIP39 passphrase for additional security
    ///
    /// # Errors
    ///
    /// Returns an error if the word count is invalid.
    ///
    /// # Note
    ///
    /// This function requires the `rand` feature to be enabled.
    #[cfg(feature = "rand")]
    pub fn generate_in(
        language: Language,
        word_count: usize,
        passphrase: Option<&str>,
    ) -> Result<Self, DeriveError> {
        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
            return Err(DeriveError::Input(alloc::format!(
                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
            )));
        }

        let mnemonic = Mnemonic::generate_in(language, word_count)?;
        Ok(Self::from_parts(&mnemonic, language, passphrase))
    }

    /// Generate a new wallet with a custom random number generator.
    ///
    /// This is useful in `no_std` environments where you provide your own
    /// cryptographically secure RNG instead of relying on the system RNG.
    ///
    /// # Arguments
    ///
    /// * `rng` - A cryptographically secure random number generator
    /// * `language` - Language for the mnemonic word list
    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
    /// * `passphrase` - Optional BIP39 passphrase for additional security
    ///
    /// # Errors
    ///
    /// Returns an error if the word count is invalid.
    ///
    /// # Note
    ///
    /// This function requires the `rand_core` feature to be enabled.
    #[cfg(feature = "rand_core")]
    pub fn generate_in_with<R>(
        rng: &mut R,
        language: Language,
        word_count: usize,
        passphrase: Option<&str>,
    ) -> Result<Self, DeriveError>
    where
        R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
    {
        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
            return Err(DeriveError::Input(alloc::format!(
                "word count must be 12, 15, 18, 21, or 24, got {word_count}"
            )));
        }

        let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
        Ok(Self::from_parts(&mnemonic, language, passphrase))
    }

    /// Create a wallet from raw entropy bytes (English by default).
    ///
    /// This is useful in `no_std` environments where you provide your own entropy
    /// source instead of relying on the system RNG.
    ///
    /// # Arguments
    ///
    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
    /// * `passphrase` - Optional BIP39 passphrase for additional security
    ///
    /// # Errors
    ///
    /// Returns an error if the entropy length is invalid.
    pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, DeriveError> {
        Self::from_entropy_in(Language::English, entropy, passphrase)
    }

    /// Create a wallet from raw entropy bytes in the specified language.
    ///
    /// This is useful in `no_std` environments where you provide your own entropy
    /// source instead of relying on the system RNG.
    ///
    /// # Arguments
    ///
    /// * `language` - Language for the mnemonic word list
    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
    /// * `passphrase` - Optional BIP39 passphrase for additional security
    ///
    /// # Errors
    ///
    /// Returns an error if the entropy length is invalid.
    pub fn from_entropy_in(
        language: Language,
        entropy: &[u8],
        passphrase: Option<&str>,
    ) -> Result<Self, DeriveError> {
        let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
        Ok(Self::from_parts(&mnemonic, language, passphrase))
    }

    /// Create a wallet from an existing mnemonic phrase.
    ///
    /// The language will be automatically detected from the phrase.
    ///
    /// # Arguments
    ///
    /// * `phrase` - BIP39 mnemonic phrase
    /// * `passphrase` - Optional BIP39 passphrase
    ///
    /// # Errors
    ///
    /// Returns an error if the mnemonic is invalid.
    pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, DeriveError> {
        let mnemonic: Mnemonic = phrase.parse()?;
        let language = mnemonic.language();
        Ok(Self::from_parts(&mnemonic, language, passphrase))
    }

    /// Expand 4-letter BIP-39 English prefixes then import (same path as CLI `import`).
    ///
    /// Full words pass through [`mnemonic::expand`](crate::mnemonic::expand) unchanged.
    ///
    /// # Errors
    ///
    /// Returns an error if expansion or BIP-39 parse fails.
    pub fn from_mnemonic_expanded(
        phrase: &str,
        passphrase: Option<&str>,
    ) -> Result<Self, DeriveError> {
        let expanded = crate::mnemonic::expand(phrase)?;
        Self::from_mnemonic(&expanded, passphrase)
    }

    /// Create a wallet from an existing mnemonic phrase in the specified language.
    ///
    /// # Arguments
    ///
    /// * `language` - Language for the mnemonic word list
    /// * `phrase` - BIP39 mnemonic phrase
    /// * `passphrase` - Optional BIP39 passphrase
    ///
    /// # Errors
    ///
    /// Returns an error if the mnemonic is invalid.
    pub fn from_mnemonic_in(
        language: Language,
        phrase: &str,
        passphrase: Option<&str>,
    ) -> Result<Self, DeriveError> {
        let mnemonic = Mnemonic::parse_in(language, phrase)?;
        Ok(Self::from_parts(&mnemonic, language, passphrase))
    }

    /// Build a wallet from a validated mnemonic, deriving the seed.
    fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
        let passphrase_str = passphrase.unwrap_or("");
        let seed_bytes = mnemonic.to_seed(passphrase_str);
        Self {
            mnemonic: Zeroizing::new(mnemonic.to_string()),
            seed: Zeroizing::new(seed_bytes),
            has_passphrase: passphrase.is_some(),
            language,
        }
    }

    /// Get the mnemonic phrase.
    ///
    /// **Security Warning**: Handle this value carefully as it can
    /// reconstruct all derived keys.
    #[inline]
    #[must_use]
    pub fn mnemonic(&self) -> &str {
        &self.mnemonic
    }

    /// Get the 64-byte BIP-39 seed, still wrapped in [`Zeroizing`].
    ///
    /// **Gated on the `raw-seed` feature** (off by default). Preferred
    /// entry points for key material are
    /// [`derive_secp256k1`](Self::derive_secp256k1) and
    /// [`derive_ed25519`](Self::derive_ed25519), which keep the seed inside
    /// [`Wallet`].
    ///
    /// Callers that enable `raw-seed` must treat the returned reference as
    /// highly sensitive: keep it borrowed or copy into another
    /// [`Zeroizing`] container.
    #[cfg(any(feature = "raw-seed", test))]
    #[inline]
    #[must_use]
    pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
        &self.seed
    }

    /// Derive a secp256k1 key pair at the given BIP-32 path.
    ///
    /// Preferred entry point for chains that derive secp256k1 keys (EVM,
    /// BTC, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
    /// Keeps the underlying seed encapsulated within [`Wallet`].
    ///
    /// # Errors
    ///
    /// Returns an error if the path is malformed or derivation fails.
    #[cfg(feature = "bip32")]
    #[inline]
    pub fn derive_secp256k1(
        &self,
        path: &str,
    ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
        crate::bip32::DerivedSecp256k1Key::derive(&self.seed, path)
    }

    /// Derive an Ed25519 key pair at the given SLIP-10 path.
    ///
    /// Preferred entry point for chains that derive Ed25519 keys (Solana,
    /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
    /// [`Wallet`].
    ///
    /// # Errors
    ///
    /// Returns an error if the path is malformed or derivation fails.
    #[cfg(feature = "slip10")]
    #[inline]
    pub fn derive_ed25519(
        &self,
        path: &str,
    ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
        crate::slip10::DerivedEd25519Key::derive_path(self.seed.as_slice(), path)
    }

    /// Check if a passphrase was supplied at construction time.
    ///
    /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
    /// even if the passphrase string itself was empty. Callers relying on
    /// "non-empty passphrase" semantics must check the passphrase string before
    /// constructing the wallet.
    #[must_use]
    pub const fn has_passphrase(&self) -> bool {
        self.has_passphrase
    }

    /// Get the language of the mnemonic.
    #[inline]
    #[must_use]
    pub const fn language(&self) -> Language {
        self.language
    }

    /// Get the word count of the mnemonic.
    #[inline]
    #[must_use]
    pub fn word_count(&self) -> usize {
        self.mnemonic.split_whitespace().count()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";

    #[cfg(feature = "rand")]
    #[test]
    fn test_generate_12_words() {
        let wallet = Wallet::generate(12, None).unwrap();
        assert_eq!(wallet.word_count(), 12);
        assert!(!wallet.has_passphrase());
    }

    #[cfg(feature = "rand")]
    #[test]
    fn test_generate_24_words() {
        let wallet = Wallet::generate(24, None).unwrap();
        assert_eq!(wallet.word_count(), 24);
    }

    #[cfg(feature = "rand")]
    #[test]
    fn test_generate_with_passphrase() {
        let wallet = Wallet::generate(12, Some("secret")).unwrap();
        assert!(wallet.has_passphrase());
    }

    #[test]
    fn test_invalid_entropy_length() {
        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
        let result = Wallet::from_entropy(&[0u8; 15], None);
        assert!(result.is_err());
    }

    #[test]
    fn test_from_entropy() {
        // 16 bytes = 12 words
        let entropy = [0u8; 16];
        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
        assert_eq!(wallet.word_count(), 12);
    }

    #[test]
    fn test_from_mnemonic() {
        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
    }

    #[test]
    fn test_passphrase_changes_seed() {
        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();

        // Same mnemonic with different passphrase should produce different seeds
        assert_ne!(wallet1.seed(), wallet2.seed());
    }

    #[test]
    fn test_deterministic_seed() {
        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
        assert_eq!(wallet1.seed(), wallet2.seed());
    }

    #[test]
    fn kat_bip39_seed_vector() {
        // BIP-39 reference: "abandon...about" with empty passphrase
        // Verified against Python pbkdf2_hmac + iancoleman.io
        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
        assert_eq!(
            hex::encode(wallet.seed()),
            "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
             9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
        );
    }

    #[test]
    fn debug_redacts_mnemonic_and_seed() {
        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
        let dbg = alloc::format!("{wallet:?}");
        assert!(
            dbg.contains("[REDACTED]"),
            "expected redaction markers: {dbg}"
        );
        assert!(
            !dbg.contains("abandon"),
            "Debug must not leak mnemonic words: {dbg}"
        );
        // BIP-39 seed hex prefix for abandon…about
        assert!(
            !dbg.contains("5eb00bbddcf06908"),
            "Debug must not leak seed bytes: {dbg}"
        );
    }

    #[test]
    fn kat_all_zero_entropy_produces_abandon_about() {
        let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
    }
}