iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Msrc/adapters/wallet_store.rs | 71++++++++++++++++++++++++++++++-----------------------------------------
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);