- EncryptionKey: remove Clone (move-only per spec), add custom redacting Debug impl, make new() private (cfg(test)), add pub(crate) key_bytes() accessor, make encrypt/decrypt pub(crate) module-internal helpers - CachedKey: refactor to wrap DerivedKey (per service.md) with cached_at and last_accessed fields; add key_type()/private_key()/public_key() accessors - Mnemonic: store validated Bip39Mnemonic to eliminate unwrap() in to_seed(); enable bip39 zeroize feature so inner is zeroized on drop - Fix clippy: remove unused import in drop_tracker tests, use struct init syntax instead of field reassignment with Default - Move low-level EncryptionKey round-trip/wrong-key tests from integration tests to unit tests (encrypt/decrypt now pub(crate))
167 lines
4.7 KiB
Rust
167 lines
4.7 KiB
Rust
//! BIP39 mnemonic generation, validation, and seed derivation.
|
|
//!
|
|
//! This module handles the root of trust: the BIP39 mnemonic seed phrase. From
|
|
//! a single mnemonic, all self-generated secrets can be derived on demand.
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! Seed material is protected with `Zeroize` to ensure it is overwritten in
|
|
//! memory before deallocation (ADR-038). The seed is never written to disk.
|
|
|
|
use bip39::Mnemonic as Bip39Mnemonic;
|
|
use zeroize::Zeroize;
|
|
|
|
/// BIP39 word list language.
|
|
///
|
|
/// Currently only English is supported, matching the BIP39 reference
|
|
/// implementation and the vast majority of wallet software.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Language {
|
|
English,
|
|
}
|
|
|
|
impl From<Language> for bip39::Language {
|
|
fn from(lang: Language) -> Self {
|
|
match lang {
|
|
Language::English => bip39::Language::English,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A BIP39 mnemonic seed phrase.
|
|
///
|
|
/// Wraps the `bip39` crate's `Mnemonic` type and provides seed derivation.
|
|
/// The internal phrase is zeroized on drop.
|
|
#[derive(Debug)]
|
|
pub struct Mnemonic {
|
|
inner: Bip39Mnemonic,
|
|
phrase: String,
|
|
}
|
|
|
|
impl Mnemonic {
|
|
/// Generate a new random mnemonic with the given word count.
|
|
///
|
|
/// Supported word counts: 12, 15, 18, 21, 24.
|
|
pub fn generate(word_count: usize) -> Result<Self, MnemonicError> {
|
|
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::generate(word_count)
|
|
.map_err(|e: bip39::Error| MnemonicError::Generation(e.to_string()))?;
|
|
Ok(Self::from_bip39(mnemonic))
|
|
}
|
|
|
|
/// Create a mnemonic from an existing phrase string.
|
|
///
|
|
/// Validates the phrase against the BIP39 word list and checksum.
|
|
pub fn from_phrase(phrase: &str, _language: Language) -> Result<Self, MnemonicError> {
|
|
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::parse_normalized(phrase)
|
|
.map_err(|e: bip39::Error| MnemonicError::InvalidPhrase(e.to_string()))?;
|
|
Ok(Self::from_bip39(mnemonic))
|
|
}
|
|
|
|
fn from_bip39(mnemonic: Bip39Mnemonic) -> Self {
|
|
let phrase = mnemonic.to_string();
|
|
Self {
|
|
inner: mnemonic,
|
|
phrase,
|
|
}
|
|
}
|
|
|
|
/// Derive the master seed from this mnemonic.
|
|
///
|
|
/// The optional passphrase is used as the BIP39 password for PBKDF2
|
|
/// key derivation (BIP39 standard). An empty string means no passphrase.
|
|
pub fn to_seed(&self, passphrase: Option<&str>) -> Seed {
|
|
let normalized_passphrase = passphrase.unwrap_or("");
|
|
let seed_bytes = self.inner.to_seed_normalized(normalized_passphrase);
|
|
Seed {
|
|
bytes: seed_bytes.to_vec(),
|
|
}
|
|
}
|
|
|
|
/// Returns the mnemonic phrase as a string.
|
|
///
|
|
/// Handle with care — this is the root of trust for all derived keys.
|
|
pub fn phrase(&self) -> &str {
|
|
&self.phrase
|
|
}
|
|
}
|
|
|
|
impl Zeroize for Mnemonic {
|
|
fn zeroize(&mut self) {
|
|
self.phrase.zeroize();
|
|
self.inner.zeroize();
|
|
}
|
|
}
|
|
|
|
impl Drop for Mnemonic {
|
|
fn drop(&mut self) {
|
|
self.zeroize();
|
|
}
|
|
}
|
|
|
|
/// A BIP39-derived master seed.
|
|
///
|
|
/// Contains the 64-byte seed material from which all HD keys are derived.
|
|
/// Zeroized on drop per ADR-038.
|
|
#[derive(Clone, Zeroize)]
|
|
#[zeroize(drop)]
|
|
pub struct Seed {
|
|
bytes: Vec<u8>,
|
|
}
|
|
|
|
impl Seed {
|
|
/// Returns the seed bytes.
|
|
///
|
|
/// These bytes are the input to SLIP-0010 master key derivation.
|
|
pub fn as_bytes(&self) -> &[u8] {
|
|
&self.bytes
|
|
}
|
|
|
|
/// Returns the length of the seed (always 64 bytes for BIP39).
|
|
pub fn len(&self) -> usize {
|
|
self.bytes.len()
|
|
}
|
|
|
|
/// Returns whether the seed is empty.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.bytes.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Errors that can occur during mnemonic operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MnemonicError {
|
|
#[error("failed to generate mnemonic: {0}")]
|
|
Generation(String),
|
|
#[error("invalid mnemonic phrase: {0}")]
|
|
InvalidPhrase(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_generate_mnemonic_24_words() {
|
|
let mnemonic = Mnemonic::generate(24).unwrap();
|
|
let words: Vec<&str> = mnemonic.phrase().split_whitespace().collect();
|
|
assert_eq!(words.len(), 24);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mnemonic_round_trip() {
|
|
let original = Mnemonic::generate(12).unwrap();
|
|
let phrase = original.phrase().to_string();
|
|
let restored = Mnemonic::from_phrase(&phrase, Language::English).unwrap();
|
|
assert_eq!(original.phrase(), restored.phrase());
|
|
}
|
|
|
|
#[test]
|
|
fn test_seed_derivation() {
|
|
let mnemonic = Mnemonic::generate(24).unwrap();
|
|
let seed = mnemonic.to_seed(None);
|
|
assert_eq!(seed.len(), 64);
|
|
assert!(!seed.is_empty());
|
|
}
|
|
}
|