commit c9037e90d61c7e80cd295f7ffc9c42746ecd56d1
parent 7ebaf78221b37d44fab30a68a663094adcb28111
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 31 Jul 2026 10:34:59 +0200
Use BIP-39 wallet seed phrases
Diffstat:
3 files changed, 91 insertions(+), 41 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -31,6 +31,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -95,6 +101,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
+name = "bip39"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
+dependencies = [
+ "bitcoin_hashes",
+ "serde",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "bitcoin_hashes"
+version = "0.14.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
+dependencies = [
+ "hex-conservative",
+]
+
+[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -397,6 +423,15 @@ dependencies = [
]
[[package]]
+name = "hex-conservative"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -506,6 +541,7 @@ version = "0.2.3"
dependencies = [
"anyhow",
"axum",
+ "bip39",
"chacha20poly1305",
"ed25519-dalek",
"getrandom 0.2.17",
@@ -948,6 +984,21 @@ dependencies = [
]
[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1036,6 +1087,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -7,6 +7,7 @@ license = "Apache-2.0"
[dependencies]
anyhow = "1.0.98"
axum = "0.8.4"
+bip39 = "2.2.2"
chacha20poly1305 = "0.10.1"
ed25519-dalek = "2.2.0"
getrandom = "0.2.17"
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -5,6 +5,7 @@ use std::{
};
use anyhow::{Context, Result, anyhow, bail};
+use bip39::{Language, Mnemonic};
use chacha20poly1305::{
ChaCha20Poly1305, KeyInit, Nonce,
aead::{Aead, Payload},
@@ -21,33 +22,7 @@ const WALLET_ENCRYPTION_ALGORITHM: &str = "chacha20poly1305";
const WALLET_ENCRYPTION_KDF: &str = "pbkdf2-sha256";
const WALLET_ENCRYPTION_ITERATIONS: u32 = 210_000;
const GENERATED_SEED_WORDS: usize = 24;
-const SEED_WORDS: &[&str] = &[
- "able", "acid", "acorn", "adapt", "agent", "anchor", "angle", "apple", "asset", "atlas",
- "badge", "balance", "beacon", "benefit", "binary", "bitter", "blanket", "border", "brave",
- "bright", "broker", "budget", "cactus", "canvas", "carbon", "castle", "census", "circle",
- "citizen", "clerk", "climate", "coffee", "copper", "corner", "cotton", "cradle", "credit",
- "crisp", "custom", "damage", "decade", "degree", "delight", "delta", "device", "dinner",
- "direct", "domain", "donor", "driver", "dynamic", "eager", "early", "earth", "echo", "economy",
- "edge", "effort", "elder", "ember", "enable", "engine", "equal", "estate", "fabric", "factor",
- "famous", "father", "feature", "federal", "fiction", "filter", "finger", "finish", "flame",
- "forest", "forum", "frost", "future", "galaxy", "garden", "gentle", "giant", "glass", "globe",
- "golden", "grain", "gravity", "green", "harbor", "hazard", "honest", "human", "humble",
- "iceberg", "impact", "income", "index", "infant", "island", "jacket", "jewel", "journal",
- "junior", "kernel", "ladder", "language", "laser", "leader", "legend", "lemon", "level",
- "liberty", "linear", "local", "lottery", "lunar", "magnet", "market", "matrix", "member",
- "memory", "merit", "method", "middle", "minute", "mirror", "model", "moment", "native",
- "network", "neutral", "noble", "normal", "notice", "novel", "object", "ocean", "offer",
- "olive", "orbit", "origin", "oxygen", "packet", "panel", "parent", "pattern", "people",
- "pepper", "permit", "planet", "plastic", "policy", "postal", "prefer", "profit", "public",
- "puzzle", "quality", "quantum", "quiet", "random", "rapid", "radius", "reason", "record",
- "region", "repair", "reward", "ribbon", "rocket", "sample", "scale", "scheme", "secret",
- "sector", "select", "senior", "shadow", "signal", "silver", "simple", "sister", "sketch",
- "social", "solar", "source", "spare", "spirit", "stable", "station", "stone", "street",
- "summer", "supply", "system", "talent", "target", "temple", "tenant", "theory", "ticket",
- "timber", "token", "travel", "treat", "trust", "tunnel", "twelve", "unique", "update",
- "useful", "valid", "valley", "vendor", "verify", "victory", "village", "virtual", "volume",
- "wallet", "window", "winter", "wonder", "yellow", "zero",
-];
+const BIP39_SEED_ENTROPY_BYTES: usize = 32;
#[derive(Debug, Serialize, Deserialize)]
struct WalletFile {
@@ -391,14 +366,12 @@ fn random_bytes<const N: usize>() -> Result<[u8; N]> {
}
fn generate_seed_phrase() -> Result<String> {
- let mut bytes = [0_u8; GENERATED_SEED_WORDS];
- getrandom::getrandom(&mut bytes)
+ let mut entropy = [0_u8; BIP39_SEED_ENTROPY_BYTES];
+ getrandom::getrandom(&mut entropy)
.map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
- let words = bytes
- .iter()
- .map(|byte| SEED_WORDS[usize::from(*byte) % SEED_WORDS.len()])
- .collect::<Vec<_>>();
- Ok(words.join(" "))
+ let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy)
+ .context("failed to generate BIP-39 seed phrase")?;
+ Ok(mnemonic.to_string())
}
fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
@@ -434,20 +407,23 @@ fn decode_hex_nibble(byte: u8) -> Result<u8> {
}
fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
- let words = seed_phrase
+ let normalized = seed_phrase
.split_whitespace()
.map(|word| word.trim().to_ascii_lowercase())
.filter(|word| !word.is_empty())
- .collect::<Vec<_>>();
- if words.len() != GENERATED_SEED_WORDS {
+ .collect::<Vec<_>>()
+ .join(" ");
+ if normalized.split_whitespace().count() != GENERATED_SEED_WORDS {
bail!("seed phrase must contain 24 words");
}
- for word in &words {
+ for word in normalized.split_whitespace() {
if !word.chars().all(|ch| ch.is_ascii_lowercase()) {
bail!("seed phrase words must contain only letters");
}
}
- Ok(words.join(" "))
+ let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
+ .context("invalid BIP-39 seed phrase")?;
+ Ok(mnemonic.to_string())
}
fn create_wallet_file(path: &Path) -> Result<File> {
@@ -486,6 +462,7 @@ fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> {
mod tests {
use std::fs;
+ use bip39::{Language, Mnemonic};
use tempfile::tempdir;
use super::{
@@ -522,6 +499,7 @@ mod tests {
let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
assert_eq!(words.len(), 24);
+ assert!(Mnemonic::parse_in_normalized(Language::English, &seed_phrase).is_ok());
assert_eq!(
setup_seed_phrase(&path).unwrap().as_deref(),
Some(seed_phrase.as_str())
@@ -620,7 +598,7 @@ mod tests {
let wallet = replace_with_imported_seed_phrase(
&path,
- " Able ACID acorn adapt agent anchor angle apple asset atlas badge balance beacon benefit binary bitter blanket border brave bright broker budget cactus canvas ",
+ " ABANDON abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art ",
)
.unwrap();
let loaded = load_or_create(&path).unwrap();
@@ -629,7 +607,7 @@ mod tests {
assert_eq!(
setup_seed_phrase(&path).unwrap().as_deref(),
Some(
- "able acid acorn adapt agent anchor angle apple asset atlas badge balance beacon benefit binary bitter blanket border brave bright broker budget cactus canvas"
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
)
);
}
@@ -644,6 +622,17 @@ mod tests {
assert!(error.to_string().contains("24 words"));
}
+ #[test]
+ fn rejects_imported_seed_phrase_with_invalid_checksum() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+ let invalid_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
+
+ let error = replace_with_imported_seed_phrase(&path, invalid_checksum).unwrap_err();
+
+ assert!(error.to_string().contains("BIP-39"));
+ }
+
fn assert_recovery_words_verify(seed_phrase: &str, indexes: &[usize]) {
let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
assert_eq!(words.len(), 24);