fix(wallet): use cryptographically secure entropy for mnemonic generation

The mnemonic/wallet generation was using a constant zero-byte array for entropy ([0u8; 32]), making all wallets deterministic based
solely on the password. This commit introduces proper random entropy using OsRng and enables users to back up their recovery phrase.

Changes:

- SeedHolder::new_mnemonic() now uses OsRng for 256-bit random entropy and returns the generated mnemonic
- Added SeedHolder::from_mnemonic() to recover a wallet from an existing mnemonic phrase
- WalletChainStore::new_storage() returns the mnemonic for user backup
- Added WalletChainStore::restore_storage() for recovery from a mnemonic
- WalletCore::new_init_storage() now returns the mnemonic
- Renamed reset_storage to restore_storage, which accepts a mnemonic for recovery
- CLI displays the recovery phrase when a new wallet is created
- RestoreKeys command now prompts for the mnemonic phrase via read_mnemonic_from_stdin()

Note: The password parameter is retained for future storage encryption but is no longer used in seed derivation (empty passphrase is used
 instead). This means the mnemonic alone is sufficient to recover accounts.

Usage:

On first wallet initialization, users will see:
IMPORTANT: Write down your recovery phrase and store it securely.
This is the only way to recover your wallet if you lose access.

Recovery phrase:
  word1 word2 word3 ... word24

To restore keys:
wallet restore-keys --depth 5
Input recovery phrase: <24 words>
Input password: <password>
This commit is contained in:
r4bbit 2026-01-20 16:47:34 +01:00
parent fb083ce91e
commit f264d159fb
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
14 changed files with 726 additions and 44 deletions

1
Cargo.lock generated
View File

@ -8650,6 +8650,7 @@ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
"base58", "base58",
"bip39",
"clap", "clap",
"common", "common",
"env_logger", "env_logger",

View File

@ -0,0 +1,545 @@
{
"sequencer_addr": "http://127.0.0.1:3040",
"seq_poll_timeout_millis": 12000,
"seq_tx_poll_max_blocks": 5,
"seq_poll_max_retries": 5,
"seq_block_poll_max_amount": 100,
"initial_accounts": [
{
"Public": {
"account_id": "BLgCRDXYdQPMMWVHYRFGQZbgeHx9frkipa8GtpG2Syqy",
"pub_sign_key": [
16,
162,
106,
154,
236,
125,
52,
184,
35,
100,
238,
174,
69,
197,
41,
77,
187,
10,
118,
75,
0,
11,
148,
238,
185,
181,
133,
17,
220,
72,
124,
77
]
}
},
{
"Public": {
"account_id": "Gj1mJy5W7J5pfmLRujmQaLfLMWidNxQ6uwnhb666ZwHw",
"pub_sign_key": [
113,
121,
64,
177,
204,
85,
229,
214,
178,
6,
109,
191,
29,
154,
63,
38,
242,
18,
244,
219,
8,
208,
35,
136,
23,
127,
207,
237,
216,
169,
190,
27
]
}
},
{
"Private": {
"account_id": "3oCG8gqdKLMegw4rRfyaMQvuPHpcASt7xwttsmnZLSkw",
"account": {
"program_owner": [
0,
0,
0,
0,
0,
0,
0,
0
],
"balance": 10000,
"data": [],
"nonce": 0
},
"key_chain": {
"secret_spending_key": [
251,
82,
235,
1,
146,
96,
30,
81,
162,
234,
33,
15,
123,
129,
116,
0,
84,
136,
176,
70,
190,
224,
161,
54,
134,
142,
154,
1,
18,
251,
242,
189
],
"private_key_holder": {
"nullifier_secret_key": [
29,
250,
10,
187,
35,
123,
180,
250,
246,
97,
216,
153,
44,
156,
16,
93,
241,
26,
174,
219,
72,
84,
34,
247,
112,
101,
217,
243,
189,
173,
75,
20
],
"incoming_viewing_secret_key": [
251,
201,
22,
154,
100,
165,
218,
108,
163,
190,
135,
91,
145,
84,
69,
241,
46,
117,
217,
110,
197,
248,
91,
193,
14,
104,
88,
103,
67,
153,
182,
158
],
"outgoing_viewing_secret_key": [
25,
67,
121,
76,
175,
100,
30,
198,
105,
123,
49,
169,
75,
178,
75,
210,
100,
143,
210,
243,
228,
243,
21,
18,
36,
84,
164,
186,
139,
113,
214,
12
]
},
"nullifer_public_key": [
63,
202,
178,
231,
183,
82,
237,
212,
216,
221,
215,
255,
153,
101,
177,
161,
254,
210,
128,
122,
54,
190,
230,
151,
183,
64,
225,
229,
113,
1,
228,
97
],
"incoming_viewing_public_key": [
3,
235,
139,
131,
237,
177,
122,
189,
6,
177,
167,
178,
202,
117,
246,
58,
28,
65,
132,
79,
220,
139,
119,
243,
187,
160,
212,
121,
61,
247,
116,
72,
205
]
}
}
},
{
"Private": {
"account_id": "AKTcXgJ1xoynta1Ec7y6Jso1z1JQtHqd7aPQ1h9er6xX",
"account": {
"program_owner": [
0,
0,
0,
0,
0,
0,
0,
0
],
"balance": 20000,
"data": [],
"nonce": 0
},
"key_chain": {
"secret_spending_key": [
238,
171,
241,
69,
111,
217,
85,
64,
19,
82,
18,
189,
32,
91,
78,
175,
107,
7,
109,
60,
52,
44,
243,
230,
72,
244,
192,
92,
137,
33,
118,
254
],
"private_key_holder": {
"nullifier_secret_key": [
25,
211,
215,
119,
57,
223,
247,
37,
245,
144,
122,
29,
118,
245,
83,
228,
23,
9,
101,
120,
88,
33,
238,
207,
128,
61,
110,
2,
89,
62,
164,
13
],
"incoming_viewing_secret_key": [
193,
181,
14,
196,
142,
84,
15,
65,
128,
101,
70,
196,
241,
47,
130,
221,
23,
146,
161,
237,
221,
40,
19,
126,
59,
15,
169,
236,
25,
105,
104,
231
],
"outgoing_viewing_secret_key": [
20,
170,
220,
108,
41,
23,
155,
217,
247,
190,
175,
168,
247,
34,
105,
134,
114,
74,
104,
91,
211,
62,
126,
13,
130,
100,
241,
214,
250,
236,
38,
150
]
},
"nullifer_public_key": [
192,
251,
166,
243,
167,
236,
84,
249,
35,
136,
130,
172,
219,
225,
161,
139,
229,
89,
243,
125,
194,
213,
209,
30,
23,
174,
100,
244,
124,
74,
140,
47
],
"incoming_viewing_public_key": [
2,
181,
98,
93,
216,
241,
241,
110,
58,
198,
119,
174,
250,
184,
1,
204,
200,
173,
44,
238,
37,
247,
170,
156,
100,
254,
116,
242,
28,
183,
187,
77,
255
]
}
}
}
]
}

View File

@ -256,11 +256,11 @@ impl TestContext {
let config_overrides = WalletConfigOverrides::default(); let config_overrides = WalletConfigOverrides::default();
let wallet_password = "test_pass".to_owned(); let wallet_password = "test_pass".to_owned();
let wallet = WalletCore::new_init_storage( let (wallet, _mnemonic) = WalletCore::new_init_storage(
config_path, config_path,
storage_path, storage_path,
Some(config_overrides), Some(config_overrides),
wallet_password.clone(), &wallet_password,
) )
.context("Failed to init wallet")?; .context("Failed to init wallet")?;
wallet wallet

View File

@ -216,7 +216,9 @@ fn new_wallet_rust_with_default_config(password: &str) -> Result<WalletCore> {
let config_path = tempdir.path().join("wallet_config.json"); let config_path = tempdir.path().join("wallet_config.json");
let storage_path = tempdir.path().join("storage.json"); let storage_path = tempdir.path().join("storage.json");
WalletCore::new_init_storage(config_path, storage_path, None, password.to_owned()) let (core, _mnemonic) =
WalletCore::new_init_storage(config_path, storage_path, None, password)?;
Ok(core)
} }
fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> { fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> {

View File

@ -42,10 +42,10 @@ impl KeyChain {
} }
#[must_use] #[must_use]
pub fn new_mnemonic(passphrase: String) -> Self { pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) {
// Currently dropping SeedHolder at the end of initialization. // Currently dropping SeedHolder at the end of initialization.
// Not entirely sure if we need it in the future. // Not entirely sure if we need it in the future.
let seed_holder = SeedHolder::new_mnemonic(passphrase); let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
let secret_spending_key = seed_holder.produce_top_secret_key_holder(); let secret_spending_key = seed_holder.produce_top_secret_key_holder();
let private_key_holder = secret_spending_key.produce_private_key_holder(None); let private_key_holder = secret_spending_key.produce_private_key_holder(None);
@ -53,12 +53,15 @@ impl KeyChain {
let nullifier_public_key = private_key_holder.generate_nullifier_public_key(); let nullifier_public_key = private_key_holder.generate_nullifier_public_key();
let viewing_public_key = private_key_holder.generate_viewing_public_key(); let viewing_public_key = private_key_holder.generate_viewing_public_key();
Self { (
secret_spending_key, Self {
private_key_holder, secret_spending_key,
nullifier_public_key, private_key_holder,
viewing_public_key, nullifier_public_key,
} viewing_public_key,
},
mnemonic,
)
} }
#[must_use] #[must_use]

View File

@ -8,8 +8,6 @@ use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest as _, digest::FixedOutput as _}; use sha2::{Digest as _, digest::FixedOutput as _};
const NSSA_ENTROPY_BYTES: [u8; 32] = [0; 32];
/// Seed holder. Non-clonable to ensure that different holders use different seeds. /// Seed holder. Non-clonable to ensure that different holders use different seeds.
/// Produces `TopSecretKeyHolder` objects. /// Produces `TopSecretKeyHolder` objects.
#[derive(Debug)] #[derive(Debug)]
@ -49,9 +47,24 @@ impl SeedHolder {
} }
#[must_use] #[must_use]
pub fn new_mnemonic(passphrase: String) -> Self { pub fn new_mnemonic(passphrase: &str) -> (Self, Mnemonic) {
let mnemonic = Mnemonic::from_entropy(&NSSA_ENTROPY_BYTES) let mut entropy_bytes: [u8; 32] = [0; 32];
.expect("Enthropy must be a multiple of 32 bytes"); OsRng.fill_bytes(&mut entropy_bytes);
let mnemonic =
Mnemonic::from_entropy(&entropy_bytes).expect("Entropy must be a multiple of 32 bytes");
let seed_wide = mnemonic.to_seed(passphrase);
(
Self {
seed: seed_wide.to_vec(),
},
mnemonic,
)
}
#[must_use]
pub fn from_mnemonic(mnemonic: &Mnemonic, passphrase: &str) -> Self {
let seed_wide = mnemonic.to_seed(passphrase); let seed_wide = mnemonic.to_seed(passphrase);
Self { Self {
@ -176,12 +189,63 @@ mod tests {
} }
#[test] #[test]
fn two_seeds_generated_same_from_same_mnemonic() { fn two_seeds_recovered_same_from_same_mnemonic() {
let mnemonic = "test_pass"; let passphrase = "test_pass";
let seed_holder1 = SeedHolder::new_mnemonic(mnemonic.to_owned()); // Generate a mnemonic with random entropy
let seed_holder2 = SeedHolder::new_mnemonic(mnemonic.to_owned()); let (original_seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
assert_eq!(seed_holder1.seed, seed_holder2.seed); // Recover from the same mnemonic
let recovered_seed_holder = SeedHolder::from_mnemonic(&mnemonic, passphrase);
assert_eq!(original_seed_holder.seed, recovered_seed_holder.seed);
}
#[test]
fn new_mnemonic_generates_different_seeds_each_time() {
let (seed_holder1, mnemonic1) = SeedHolder::new_mnemonic("");
let (seed_holder2, mnemonic2) = SeedHolder::new_mnemonic("");
// Different entropy should produce different mnemonics and seeds
assert_ne!(mnemonic1.to_string(), mnemonic2.to_string());
assert_ne!(seed_holder1.seed, seed_holder2.seed);
}
#[test]
fn new_mnemonic_generates_24_word_phrase() {
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
// 256 bits of entropy produces a 24-word mnemonic
let word_count = mnemonic.to_string().split_whitespace().count();
assert_eq!(word_count, 24);
}
#[test]
fn new_mnemonic_produces_valid_seed_length() {
let (seed_holder, _mnemonic) = SeedHolder::new_mnemonic("");
assert_eq!(seed_holder.seed.len(), 64);
}
#[test]
fn different_passphrases_produce_different_seeds() {
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
let seed_with_pass_a = SeedHolder::from_mnemonic(&mnemonic, "password_a");
let seed_with_pass_b = SeedHolder::from_mnemonic(&mnemonic, "password_b");
// Same mnemonic but different passphrases should produce different seeds
assert_ne!(seed_with_pass_a.seed, seed_with_pass_b.seed);
}
#[test]
fn empty_passphrase_is_deterministic() {
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
let seed1 = SeedHolder::from_mnemonic(&mnemonic, "");
let seed2 = SeedHolder::from_mnemonic(&mnemonic, "");
// Same mnemonic and passphrase should always produce the same seed
assert_eq!(seed1.seed, seed2.seed);
} }
} }

View File

@ -181,11 +181,12 @@ impl NSSAUserData {
impl Default for NSSAUserData { impl Default for NSSAUserData {
fn default() -> Self { fn default() -> Self {
let (seed_holder, _mnemonic) = SeedHolder::new_mnemonic("");
Self::new_with_accounts( Self::new_with_accounts(
BTreeMap::new(), BTreeMap::new(),
BTreeMap::new(), BTreeMap::new(),
KeyTreePublic::new(&SeedHolder::new_mnemonic("default".to_owned())), KeyTreePublic::new(&seed_holder),
KeyTreePrivate::new(&SeedHolder::new_mnemonic("default".to_owned())), KeyTreePrivate::new(&seed_holder),
) )
.unwrap() .unwrap()
} }

View File

@ -111,8 +111,8 @@ pub unsafe extern "C" fn wallet_ffi_create_new(
return ptr::null_mut(); return ptr::null_mut();
}; };
match WalletCore::new_init_storage(config_path, storage_path, None, password) { match WalletCore::new_init_storage(config_path, storage_path, None, &password) {
Ok(core) => { Ok((core, _mnemonic)) => {
let wrapper = Box::new(WalletWrapper { let wrapper = Box::new(WalletWrapper {
core: Mutex::new(core), core: Mutex::new(core),
}); });

View File

@ -15,6 +15,7 @@ key_protocol.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] }
token_core.workspace = true token_core.workspace = true
amm_core.workspace = true amm_core.workspace = true
bip39.workspace = true
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@ -1,6 +1,7 @@
use std::collections::{BTreeMap, HashMap, btree_map::Entry}; use std::collections::{BTreeMap, HashMap, btree_map::Entry};
use anyhow::Result; use anyhow::Result;
use bip39::Mnemonic;
use key_protocol::{ use key_protocol::{
key_management::{ key_management::{
key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex}, key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex},
@ -95,7 +96,7 @@ impl WalletChainStore {
}) })
} }
pub fn new_storage(config: WalletConfig, password: String) -> Result<Self> { pub fn new_storage(config: WalletConfig, password: &str) -> Result<(Self, Mnemonic)> {
let mut public_init_acc_map = BTreeMap::new(); let mut public_init_acc_map = BTreeMap::new();
let mut private_init_acc_map = BTreeMap::new(); let mut private_init_acc_map = BTreeMap::new();
@ -115,13 +116,43 @@ impl WalletChainStore {
} }
} }
let public_tree = KeyTreePublic::new(&SeedHolder::new_mnemonic(password.clone())); // TODO: Use password for storage encryption
let private_tree = KeyTreePrivate::new(&SeedHolder::new_mnemonic(password)); let _ = password;
let (seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
let public_tree = KeyTreePublic::new(&seed_holder);
let private_tree = KeyTreePrivate::new(&seed_holder);
Ok((
Self {
user_data: NSSAUserData::new_with_accounts(
public_init_acc_map,
private_init_acc_map,
public_tree,
private_tree,
)?,
wallet_config: config,
labels: HashMap::new(),
},
mnemonic,
))
}
/// Restore storage from an existing mnemonic phrase.
pub fn restore_storage(
config: WalletConfig,
mnemonic: &Mnemonic,
password: &str,
) -> Result<Self> {
// TODO: Use password for storage encryption
let _ = password;
let seed_holder = SeedHolder::from_mnemonic(mnemonic, "");
let public_tree = KeyTreePublic::new(&seed_holder);
let private_tree = KeyTreePrivate::new(&seed_holder);
Ok(Self { Ok(Self {
user_data: NSSAUserData::new_with_accounts( user_data: NSSAUserData::new_with_accounts(
public_init_acc_map, BTreeMap::new(),
private_init_acc_map, BTreeMap::new(),
public_tree, public_tree,
private_tree, private_tree,
)?, )?,

View File

@ -1,6 +1,7 @@
use std::{io::Write as _, path::PathBuf}; use std::{io::Write as _, path::PathBuf, str::FromStr as _};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use bip39::Mnemonic;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use common::{HashType, transaction::NSSATransaction}; use common::{HashType, transaction::NSSATransaction};
use futures::TryFutureExt as _; use futures::TryFutureExt as _;
@ -162,8 +163,9 @@ pub async fn execute_subcommand(
config_subcommand.handle_subcommand(wallet_core).await? config_subcommand.handle_subcommand(wallet_core).await?
} }
Command::RestoreKeys { depth } => { Command::RestoreKeys { depth } => {
let mnemonic = read_mnemonic_from_stdin()?;
let password = read_password_from_stdin()?; let password = read_password_from_stdin()?;
wallet_core.reset_storage(password)?; wallet_core.restore_storage(&mnemonic, &password)?;
execute_keys_restoration(wallet_core, depth).await?; execute_keys_restoration(wallet_core, depth).await?;
SubcommandReturnValue::Empty SubcommandReturnValue::Empty
@ -207,6 +209,16 @@ pub fn read_password_from_stdin() -> Result<String> {
Ok(password.trim().to_owned()) Ok(password.trim().to_owned())
} }
pub fn read_mnemonic_from_stdin() -> Result<Mnemonic> {
let mut phrase = String::new();
print!("Input recovery phrase: ");
std::io::stdout().flush()?;
std::io::stdin().read_line(&mut phrase)?;
Mnemonic::from_str(phrase.trim()).context("Invalid mnemonic phrase")
}
pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32) -> Result<()> { pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32) -> Result<()> {
wallet_core wallet_core
.storage .storage

View File

@ -222,10 +222,10 @@ impl Default for WalletConfig {
let public_key2 = nssa::PublicKey::new_from_private_key(&pub_sign_key2); let public_key2 = nssa::PublicKey::new_from_private_key(&pub_sign_key2);
let public_account_id2 = nssa::AccountId::from(&public_key2); let public_account_id2 = nssa::AccountId::from(&public_key2);
let key_chain1 = KeyChain::new_mnemonic("default_private_account_1".to_owned()); let (key_chain1, _) = KeyChain::new_mnemonic("default_private_account_1");
let private_account_id1 = nssa::AccountId::from(&key_chain1.nullifier_public_key); let private_account_id1 = nssa::AccountId::from(&key_chain1.nullifier_public_key);
let key_chain2 = KeyChain::new_mnemonic("default_private_account_2".to_owned()); let (key_chain2, _) = KeyChain::new_mnemonic("default_private_account_2");
let private_account_id2 = nssa::AccountId::from(&key_chain2.nullifier_public_key); let private_account_id2 = nssa::AccountId::from(&key_chain2.nullifier_public_key);
Self { Self {

View File

@ -11,6 +11,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use bip39::Mnemonic;
use chain_storage::WalletChainStore; use chain_storage::WalletChainStore;
use common::{HashType, transaction::NSSATransaction}; use common::{HashType, transaction::NSSATransaction};
use config::WalletConfig; use config::WalletConfig;
@ -115,15 +116,24 @@ impl WalletCore {
config_path: PathBuf, config_path: PathBuf,
storage_path: PathBuf, storage_path: PathBuf,
config_overrides: Option<WalletConfigOverrides>, config_overrides: Option<WalletConfigOverrides>,
password: String, password: &str,
) -> Result<Self> { ) -> Result<(Self, Mnemonic)> {
Self::new( let mut mnemonic_out = None;
let wallet = Self::new(
config_path, config_path,
storage_path, storage_path,
config_overrides, config_overrides,
|config| WalletChainStore::new_storage(config, password), |config| {
let (storage, mnemonic) = WalletChainStore::new_storage(config, password)?;
mnemonic_out = Some(mnemonic);
Ok(storage)
},
0, 0,
) )?;
Ok((
wallet,
mnemonic_out.expect("mnemonic should be set after new_storage"),
))
} }
fn new( fn new(
@ -189,9 +199,13 @@ impl WalletCore {
&self.storage &self.storage
} }
/// Reset storage. /// Restore storage from an existing mnemonic phrase.
pub fn reset_storage(&mut self, password: String) -> Result<()> { pub fn restore_storage(&mut self, mnemonic: &Mnemonic, password: &str) -> Result<()> {
self.storage = WalletChainStore::new_storage(self.storage.wallet_config.clone(), password)?; self.storage = WalletChainStore::restore_storage(
self.storage.wallet_config.clone(),
mnemonic,
password,
)?;
Ok(()) Ok(())
} }

View File

@ -46,13 +46,21 @@ async fn main() -> Result<()> {
println!("Persistent storage not found, need to execute setup"); println!("Persistent storage not found, need to execute setup");
let password = read_password_from_stdin()?; let password = read_password_from_stdin()?;
let wallet = WalletCore::new_init_storage( let (wallet, mnemonic) = WalletCore::new_init_storage(
config_path, config_path,
storage_path, storage_path,
Some(config_overrides), Some(config_overrides),
password, &password,
)?; )?;
println!();
println!("IMPORTANT: Write down your recovery phrase and store it securely.");
println!("This is the only way to recover your wallet if you lose access.");
println!();
println!("Recovery phrase:");
println!(" {mnemonic}");
println!();
wallet.store_persistent_data().await?; wallet.store_persistent_data().await?;
wallet wallet
}; };