iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 5791f94e562947eb7a8d91e68233bf98a403139d
parent 411dd9b37efcc95c37b1384c1a752463d286fb04
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  9 Sep 2026 14:58:24 +0200

feat(security): protect in-memory secrets

Diffstat:
MCargo.lock | 11+++++++++++
MCargo.toml | 1+
Msrc/adapters/http/actions.rs | 7+++++--
Msrc/adapters/http/auth.rs | 35+++++++++++++++++++++++------------
Msrc/adapters/http/auth_routes.rs | 15++++-----------
Msrc/adapters/http/request_auth.rs | 77+++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/adapters/http/state.rs | 5+++--
Msrc/adapters/http/types.rs | 49+++++++++++++++++++++++++++++++++++++++++++------
Msrc/adapters/http/wallet.rs | 14++++++++++----
Msrc/adapters/p2p/identity.rs | 18+++++++++++-------
Msrc/adapters/wallet_store.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/domain/wallet.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/main.rs | 16++++++++++++----
13 files changed, 344 insertions(+), 162 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -597,6 +597,7 @@ dependencies = [ "pbkdf2", "proptest", "rusqlite", + "secrecy", "serde", "serde_json", "sha2", @@ -992,6 +993,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -22,6 +22,7 @@ sha2 = "0.10.9" rusqlite = { version = "0.32.1", features = ["bundled"] } tokio = { version = "1.45.1", features = ["full"] } kyn-vdf = "=0.1.1" +secrecy = { version = "0.10.3", default-features = false, features = ["serde"] } [features] e2e = [] diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -7,6 +7,7 @@ use axum::{ http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; +use secrecy::ExposeSecret; use tokio::sync::Mutex; use super::types::{ @@ -58,7 +59,9 @@ pub(super) async fn api_wallet_import_form( headers: HeaderMap, Form(form): Form<SeedPhraseForm>, ) -> Json<WalletSetupResponse> { - wallet_setup_json(super::import_setup_wallet_seed(&state, &headers, &form.seed_phrase).await) + wallet_setup_json( + super::import_setup_wallet_seed(&state, &headers, form.seed_phrase.expose_secret()).await, + ) } pub(super) async fn api_transfer_fee_estimate_form( @@ -680,7 +683,7 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), - setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))), + setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".into()))), management_port: 9444, wallet_endpoint_addr: None, }; diff --git a/src/adapters/http/auth.rs b/src/adapters/http/auth.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, bail}; use getrandom::getrandom; use pbkdf2::pbkdf2_hmac; +use secrecy::{ExposeSecret, SecretBox, SecretString}; use sha2::{Digest, Sha256}; const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; @@ -24,7 +25,7 @@ pub(super) fn hash_password(password: &str) -> Result<String> { Ok(format!( "{PASSWORD_KDF_ALGORITHM}${PASSWORD_KDF_ITERATIONS}${}${}", hex_encode(salt), - hex_encode(hash) + hex_encode(hash.expose_secret()) )) } @@ -40,7 +41,7 @@ pub(super) fn verify_password(password: &str, encoded: &str) -> Result<bool> { let salt = decode_hex(parts[2]).context("invalid password hash salt")?; let expected = decode_hex(parts[3]).context("invalid password hash")?; let actual = pbkdf2_sha256(password.as_bytes(), &salt, iterations); - Ok(constant_time_eq(&actual, &expected)) + Ok(constant_time_eq(actual.expose_secret(), &expected)) } fn validate_password_kdf_iterations(iterations: u32) -> Result<()> { @@ -51,20 +52,30 @@ fn validate_password_kdf_iterations(iterations: u32) -> Result<()> { } pub(super) fn session_token_hash(token: &str) -> String { - hex_encode(Sha256::digest(format!("iuna-session:{token}").as_bytes())) + let mut hasher = Sha256::new(); + hasher.update(b"iuna-session:"); + hasher.update(token.as_bytes()); + hex_encode(hasher.finalize()) } -pub(super) fn random_hex(bytes: usize) -> Result<String> { - let mut value = vec![0_u8; bytes]; - getrandom(&mut value) - .map_err(|error| anyhow::anyhow!("secure random generation failed: {error}"))?; - Ok(hex_encode(value)) +pub(super) fn random_hex(bytes: usize) -> Result<SecretString> { + let mut random_error = None; + let value = SecretBox::<Vec<u8>>::init_with_mut(|value| { + value.resize(bytes, 0); + if let Err(error) = getrandom(value) { + random_error = Some(error); + } + }); + if let Some(error) = random_error { + return Err(anyhow::anyhow!("secure random generation failed: {error}")); + } + Ok(hex_encode(value.expose_secret()).into()) } -pub(super) fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] { - let mut output = [0_u8; 32]; - pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut output); - output +pub(super) fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> SecretBox<[u8; 32]> { + SecretBox::init_with_mut(|output: &mut [u8; 32]| { + pbkdf2_hmac::<Sha256>(password, salt, iterations, output); + }) } fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { diff --git a/src/adapters/http/auth_routes.rs b/src/adapters/http/auth_routes.rs @@ -88,7 +88,7 @@ pub(super) async fn api_auth_setup_form( if let Err(error) = validate_setup_capability(&state, &headers).await { return (StatusCode::FORBIDDEN, action_json(Err(error))).into_response(); } - match setup_auth_password(&state, &form.password, &client_key.0).await { + match setup_auth_password(&state, form.password, &client_key.0).await { Ok(cookie) => { consume_setup_capability(&state).await; let mut response = action_json(Ok(())).into_response(); @@ -113,7 +113,7 @@ pub(super) async fn api_auth_login_form( Extension(client_key): Extension<AuthClientKey>, Form(form): Form<AuthForm>, ) -> Response { - match login_auth_password(&state, &form.password, &client_key.0).await { + match login_auth_password(&state, form.password, &client_key.0).await { Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), Err(error) => action_json(Err(error)).into_response(), } @@ -145,14 +145,7 @@ pub(super) async fn api_auth_change_password_form( Extension(client_key): Extension<AuthClientKey>, Form(form): Form<ChangePasswordForm>, ) -> Response { - match change_auth_password( - &state, - &form.old_password, - &form.new_password, - &client_key.0, - ) - .await - { + match change_auth_password(&state, form.old_password, form.new_password, &client_key.0).await { Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), Err(error) => action_json(Err(error)).into_response(), } @@ -289,7 +282,7 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), - setup_capability: Arc::new(Mutex::new(Some(SETUP_CAPABILITY.to_string()))), + setup_capability: Arc::new(Mutex::new(Some(SETUP_CAPABILITY.into()))), management_port: MANAGEMENT_PORT, wallet_endpoint_addr: None, } diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs @@ -1,10 +1,12 @@ use std::{ net::{IpAddr, SocketAddr}, str::FromStr, + sync::Arc, }; use anyhow::{Context, Result, bail}; use axum::http::{HeaderMap, Method, Uri, header, uri::Authority}; +use secrecy::{ExposeSecret, SecretString}; use crate::{ adapters::{config_store, wallet_store}, @@ -157,7 +159,8 @@ pub(super) async fn request_is_authenticated(state: &HttpState, headers: &Header pub(super) async fn setup_capability_cookie(state: &HttpState) -> Option<String> { state.setup_capability.lock().await.as_ref().map(|token| { format!( - "{SETUP_COOKIE_NAME}={token}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age={SETUP_COOKIE_TTL_SECS}" + "{SETUP_COOKIE_NAME}={}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age={SETUP_COOKIE_TTL_SECS}", + token.expose_secret() ) }) } @@ -168,13 +171,11 @@ pub(super) async fn validate_setup_capability( ) -> Result<()> { let supplied = named_cookie(headers, SETUP_COOKIE_NAME) .context("local password setup capability is required")?; - let expected = state - .setup_capability - .lock() - .await - .clone() + let capability = state.setup_capability.lock().await; + let expected = capability + .as_ref() .context("local password setup capability is no longer available")?; - if session_token_hash(supplied) != session_token_hash(&expected) { + if session_token_hash(supplied) != session_token_hash(expected.expose_secret()) { bail!("local password setup capability is invalid"); } Ok(()) @@ -187,7 +188,7 @@ pub(super) async fn consume_setup_capability(state: &HttpState) { pub(super) async fn wallet_password_for_request( state: &HttpState, headers: &HeaderMap, -) -> Option<String> { +) -> Option<Arc<SecretString>> { let token = auth_cookie(headers)?; let token_hash = session_token_hash(token); let now = now_ms(); @@ -250,11 +251,12 @@ fn forwarded_header_client(headers: &HeaderMap) -> Option<String> { pub(super) async fn setup_auth_password( state: &HttpState, - password: &str, + password: SecretString, client_key: &str, ) -> Result<String> { + let exposed_password = password.expose_secret(); check_auth_backoff(state, client_key).await?; - if let Err(error) = validate_password(password) { + if let Err(error) = validate_password(exposed_password) { record_auth_failure(state, client_key).await; return Err(error); } @@ -263,21 +265,22 @@ pub(super) async fn setup_auth_password( record_auth_failure(state, client_key).await; bail!("authentication is already configured"); } - config.auth_password_hash = Some(hash_password(password)?); + config.auth_password_hash = Some(hash_password(exposed_password)?); config_store::save(&state.config_path, &config)?; drop(config); - wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; - let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - restore_node_wallet_from_store(state, wallet, Some(password)).await?; + wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?; + let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?; + restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?; clear_auth_backoff(state, client_key).await; create_session_cookie(state, password).await } pub(super) async fn login_auth_password( state: &HttpState, - password: &str, + password: SecretString, client_key: &str, ) -> Result<String> { + let exposed_password = password.expose_secret(); check_auth_backoff(state, client_key).await?; let hash = state .ui_config @@ -286,25 +289,27 @@ pub(super) async fn login_auth_password( .auth_password_hash .clone() .context("authentication setup is required")?; - if !verify_password(password, &hash)? { + if !verify_password(exposed_password, &hash)? { record_auth_failure(state, client_key).await; bail!("invalid password"); } - wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; - let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - restore_node_wallet_from_store(state, wallet, Some(password)).await?; + wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?; + let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?; + restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?; clear_auth_backoff(state, client_key).await; create_session_cookie(state, password).await } pub(super) async fn change_auth_password( state: &HttpState, - old_password: &str, - new_password: &str, + old_password: SecretString, + new_password: SecretString, client_key: &str, ) -> Result<String> { + let exposed_old_password = old_password.expose_secret(); + let exposed_new_password = new_password.expose_secret(); check_auth_backoff(state, client_key).await?; - validate_password(new_password)?; + validate_password(exposed_new_password)?; let current_hash = state .ui_config .lock() @@ -312,18 +317,21 @@ pub(super) async fn change_auth_password( .auth_password_hash .clone() .context("authentication setup is required")?; - if !verify_password(old_password, &current_hash)? { + if !verify_password(exposed_old_password, &current_hash)? { record_auth_failure(state, client_key).await; bail!("invalid current password"); } - let wallet = - wallet_store::reencrypt_with_password(&state.wallet_path, old_password, new_password)?; + let wallet = wallet_store::reencrypt_with_password( + &state.wallet_path, + exposed_old_password, + exposed_new_password, + )?; { let mut config = state.ui_config.lock().await; - config.auth_password_hash = Some(hash_password(new_password)?); + config.auth_password_hash = Some(hash_password(exposed_new_password)?); config_store::save(&state.config_path, &config)?; } - restore_node_wallet_from_store(state, wallet, Some(new_password)).await?; + restore_node_wallet_from_store(state, wallet, Some(exposed_new_password)).await?; state.auth_sessions.lock().await.clear(); clear_auth_backoff(state, client_key).await; create_session_cookie(state, new_password).await @@ -369,19 +377,20 @@ async fn clear_auth_backoff(state: &HttpState, client_key: &str) { state.auth_backoff.lock().await.remove(client_key); } -async fn create_session_cookie(state: &HttpState, password: &str) -> Result<String> { +async fn create_session_cookie(state: &HttpState, password: SecretString) -> Result<String> { let token = random_hex(32)?; - let token_hash = session_token_hash(&token); + let token_hash = session_token_hash(token.expose_secret()); let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS); state.auth_sessions.lock().await.insert( token_hash, AuthSession { expires_at, - wallet_password: password.to_string(), + wallet_password: Arc::new(password), }, ); Ok(format!( - "{AUTH_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + "{AUTH_COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + token.expose_secret(), AUTH_SESSION_TTL_MS / 1000 )) } @@ -480,7 +489,7 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), - setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))), + setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".into()))), management_port: 9444, wallet_endpoint_addr: None, } @@ -724,14 +733,14 @@ mod tests { session_token_hash(expired_token), AuthSession { expires_at: now_ms().saturating_sub(1), - wallet_password: "expired-password".to_string(), + wallet_password: Arc::new("expired-password".into()), }, ); state.auth_sessions.lock().await.insert( session_token_hash(live_token), AuthSession { expires_at: now_ms().saturating_add(60_000), - wallet_password: "live-password".to_string(), + wallet_password: Arc::new("live-password".into()), }, ); diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -1,5 +1,6 @@ use std::{collections::BTreeMap, net::SocketAddr, path::PathBuf, sync::Arc}; +use secrecy::SecretString; use tokio::sync::Mutex; use crate::{ @@ -23,7 +24,7 @@ pub(super) struct HttpState { pub(super) stratum: StratumStatus, pub(super) auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, - pub(super) setup_capability: Arc<Mutex<Option<String>>>, + pub(super) setup_capability: Arc<Mutex<Option<SecretString>>>, pub(super) management_port: u16, pub(super) wallet_endpoint_addr: Option<SocketAddr>, } @@ -31,7 +32,7 @@ pub(super) struct HttpState { #[derive(Clone)] pub(super) struct AuthSession { pub(super) expires_at: u64, - pub(super) wallet_password: String, + pub(super) wallet_password: Arc<SecretString>, } #[derive(Clone, Debug)] diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -1,4 +1,5 @@ -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize, Serializer}; use crate::{ adapters::{config_store::UiConfig, ui_data_store::BlockMetricRow}, @@ -7,13 +8,13 @@ use crate::{ #[derive(Debug, Deserialize)] pub(super) struct AuthForm { - pub(super) password: String, + pub(super) password: SecretString, } #[derive(Debug, Deserialize)] pub(super) struct ChangePasswordForm { - pub(super) old_password: String, - pub(super) new_password: String, + pub(super) old_password: SecretString, + pub(super) new_password: SecretString, } #[derive(Debug, Serialize)] @@ -197,7 +198,7 @@ pub(super) struct ConfigForm { #[derive(Debug, Deserialize)] pub(super) struct SeedPhraseForm { - pub(super) seed_phrase: String, + pub(super) seed_phrase: SecretString, } #[derive(Debug, Deserialize)] @@ -368,11 +369,47 @@ pub(super) struct WalletSetupResponse { pub(super) ok: bool, pub(super) error: Option<String>, pub(super) address: Option<String>, - pub(super) seed_phrase: Option<String>, + #[serde(serialize_with = "serialize_optional_secret")] + pub(super) seed_phrase: Option<SecretString>, pub(super) dev_verify_bypass: bool, pub(super) requires_peer: bool, } +fn serialize_optional_secret<S>( + value: &Option<SecretString>, + serializer: S, +) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + match value { + Some(secret) => serializer.serialize_some(secret.expose_secret()), + None => serializer.serialize_none(), + } +} + +#[cfg(test)] +mod secret_tests { + use super::{AuthForm, ChangePasswordForm}; + + #[test] + fn auth_form_debug_output_redacts_passwords() { + let auth = AuthForm { + password: "correct-horse-battery-staple".into(), + }; + let change = ChangePasswordForm { + old_password: "old-password-value".into(), + new_password: "new-password-value".into(), + }; + + let debug = format!("{auth:?} {change:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("correct-horse-battery-staple")); + assert!(!debug.contains("old-password-value")); + assert!(!debug.contains("new-password-value")); + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct WalletTransactionRow { diff --git a/src/adapters/http/wallet.rs b/src/adapters/http/wallet.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result, bail}; use axum::{Json, extract::State, http::HeaderMap}; +use secrecy::ExposeSecret; use crate::{ adapters::wallet_store, @@ -30,7 +31,10 @@ pub(super) async fn wallet_setup_response( let seed_phrase = if setup_complete && !migration_required { None } else { - wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())? + wallet_store::setup_seed_phrase_with_password( + &state.wallet_path, + password.as_ref().map(|password| password.expose_secret()), + )? }; let address = state.node.lock().await.wallet_receive_address()?; Ok(WalletSetupResponse { @@ -55,8 +59,10 @@ pub(super) async fn replace_setup_wallet_with_generated_seed( let password = wallet_password_for_request(state, headers) .await .context("wallet password session is required")?; - let (wallet, seed_phrase) = - wallet_store::replace_with_generated_seed_phrase_encrypted(&state.wallet_path, &password)?; + let (wallet, seed_phrase) = wallet_store::replace_with_generated_seed_phrase_encrypted( + &state.wallet_path, + password.expose_secret(), + )?; state.node.lock().await.replace_wallet(wallet); let address = state.node.lock().await.wallet_receive_address()?; Ok(WalletSetupResponse { @@ -81,7 +87,7 @@ pub(super) async fn import_setup_wallet_seed( let wallet = wallet_store::replace_with_imported_seed_phrase_encrypted( &state.wallet_path, seed_phrase, - &password, + password.expose_secret(), )?; state.node.lock().await.replace_wallet(wallet); let address = state.node.lock().await.wallet_receive_address()?; diff --git a/src/adapters/p2p/identity.rs b/src/adapters/p2p/identity.rs @@ -5,26 +5,29 @@ use std::{ use anyhow::Result; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use secrecy::{ExposeSecret, SecretBox}; use crate::app::{GossipEnvelope, NETWORK_ID}; use super::GossipNetwork; -static NODE_SIGNING_KEYS: OnceLock<StdMutex<BTreeMap<String, SigningKey>>> = OnceLock::new(); +static NODE_SIGNING_KEYS: OnceLock<StdMutex<BTreeMap<String, SecretBox<[u8; 32]>>>> = + OnceLock::new(); pub(super) fn new_node_id() -> String { - let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes).expect("secure randomness unavailable for p2p node id"); - let signing_key = SigningKey::from_bytes(&bytes); + let signing_seed = SecretBox::init_with_mut(|bytes: &mut [u8; 32]| { + getrandom::getrandom(bytes).expect("secure randomness unavailable for p2p node id"); + }); + let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); let node_id = hex_encode(&signing_key.verifying_key().to_bytes()); node_signing_keys() .lock() .expect("node signing key registry mutex poisoned") - .insert(node_id.clone(), signing_key); + .insert(node_id.clone(), signing_seed); node_id } -fn node_signing_keys() -> &'static StdMutex<BTreeMap<String, SigningKey>> { +fn node_signing_keys() -> &'static StdMutex<BTreeMap<String, SecretBox<[u8; 32]>>> { NODE_SIGNING_KEYS.get_or_init(|| StdMutex::new(BTreeMap::new())) } @@ -87,7 +90,8 @@ pub(super) fn peer_verification_response_for_node_id( let keys = node_signing_keys() .lock() .expect("node signing key registry mutex poisoned"); - let signing_key = keys.get(node_id)?; + let signing_seed = keys.get(node_id)?; + let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); let payload = peer_verification_payload(address, nonce, node_id); let signature: Signature = signing_key.sign(payload.as_bytes()); Some(GossipEnvelope::PeerVerificationResponse { diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -12,7 +12,8 @@ use chacha20poly1305::{ aead::{Aead, Payload}, }; use pbkdf2::pbkdf2_hmac; -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretBox, SecretString}; +use serde::{Deserialize, Serialize, Serializer}; use sha2::Sha256; use crate::domain::Wallet; @@ -32,16 +33,21 @@ const BIP39_SEED_ENTROPY_BYTES: usize = 32; #[derive(Debug, Serialize, Deserialize)] struct WalletFile { version: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - seed: Option<String>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_secret" + )] + seed: Option<SecretString>, address: String, #[serde(default, skip_serializing_if = "Option::is_none")] encryption: Option<EncryptedWalletSeed>, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Debug, Deserialize, Serialize)] struct WalletData { - seed: String, + #[serde(serialize_with = "serialize_secret")] + seed: SecretString, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -60,19 +66,39 @@ struct EncryptedWalletSeed { ciphertext: String, } +fn serialize_secret<S>(value: &SecretString, serializer: S) -> std::result::Result<S::Ok, S::Error> +where + S: Serializer, +{ + serializer.serialize_str(value.expose_secret()) +} + +fn serialize_optional_secret<S>( + value: &Option<SecretString>, + serializer: S, +) -> std::result::Result<S::Ok, S::Error> +where + S: Serializer, +{ + match value { + Some(secret) => serializer.serialize_some(secret.expose_secret()), + None => serializer.serialize_none(), + } +} + pub fn load_or_create(path: &Path) -> Result<Wallet> { if path.exists() { return load(path); } let seed = generate_seed_phrase()?; - let wallet = Wallet::from_seed(&seed); + let wallet = Wallet::from_seed(seed.expose_secret()); write_wallet_file(path, seed, wallet.address(), WalletFileMode::CreateNew)?; Ok(wallet) } -pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String)> { +pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, SecretString)> { let seed = generate_seed_phrase()?; let wallet = write_wallet(path, seed.clone(), WalletFileMode::Replace)?; Ok((wallet, seed)) @@ -81,7 +107,7 @@ pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String pub fn replace_with_generated_seed_phrase_encrypted( path: &Path, password: &str, -) -> Result<(Wallet, String)> { +) -> Result<(Wallet, SecretString)> { let seed = generate_seed_phrase()?; let wallet = write_wallet_encrypted(path, seed.clone(), password, WalletFileMode::Replace)?; Ok((wallet, seed)) @@ -101,14 +127,14 @@ pub fn replace_with_imported_seed_phrase_encrypted( write_wallet_encrypted(path, seed, password, WalletFileMode::Replace) } -pub fn setup_seed_phrase(path: &Path) -> Result<Option<String>> { +pub fn setup_seed_phrase(path: &Path) -> Result<Option<SecretString>> { setup_seed_phrase_with_password(path, None) } pub fn setup_seed_phrase_with_password( path: &Path, password: Option<&str>, -) -> Result<Option<String>> { +) -> Result<Option<SecretString>> { if !path.exists() { return Ok(None); } @@ -117,11 +143,11 @@ pub fn setup_seed_phrase_with_password( Ok(seed) => seed, Err(_) => return Ok(None), }; - let normalized = match normalize_seed_phrase(&seed) { + let normalized = match normalize_seed_phrase(seed.expose_secret()) { Ok(seed) => seed, Err(_) => return Ok(None), }; - if normalized == seed { + if normalized.expose_secret() == seed.expose_secret() { Ok(Some(normalized)) } else { Ok(None) @@ -154,8 +180,8 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> } let data = wallet_data(&stored, None)?; let seed = data.seed; - let seed = normalize_seed_phrase(&seed).unwrap_or(seed); - let wallet = Wallet::from_seed(&seed); + let seed = normalize_seed_phrase(seed.expose_secret()).unwrap_or(seed); + let wallet = Wallet::from_seed(seed.expose_secret()); if wallet.address() != stored.address { bail!( "wallet file has address {}, but its seed derives {}", @@ -181,8 +207,8 @@ pub fn reencrypt_with_password( let stored = read_wallet_file(path)?; let data = wallet_data(&stored, Some(current_password))?; let seed = data.seed; - let seed = normalize_seed_phrase(&seed).unwrap_or(seed); - let wallet = Wallet::from_seed(&seed); + let seed = normalize_seed_phrase(seed.expose_secret()).unwrap_or(seed); + let wallet = Wallet::from_seed(seed.expose_secret()); if wallet.address() != stored.address { bail!( "wallet file has address {}, but its seed derives {}", @@ -237,10 +263,10 @@ fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wa fn wallet_from_stored(stored: &WalletFile, password: Option<&str>) -> Result<Wallet> { let seed = wallet_seed(stored, password)?; - Ok(Wallet::from_seed(&seed)) + Ok(Wallet::from_seed(seed.expose_secret())) } -fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> { +fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<SecretString> { if let Some(encryption) = &stored.encryption { let password = password.context("wallet is encrypted; unlock it with the UI password")?; return decrypt_seed(encryption, &stored.address, password); @@ -253,8 +279,10 @@ fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> { fn read_wallet_file(path: &Path) -> Result<WalletFile> { let bytes = - fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?; - parse_wallet_file_bytes(&bytes, &path.display().to_string()) + SecretBox::new(Box::new(fs::read(path).with_context(|| { + format!("failed to read wallet file {}", path.display()) + })?)); + parse_wallet_file_bytes(bytes.expose_secret(), &path.display().to_string()) } fn parse_wallet_file_bytes(bytes: &[u8], source: &str) -> Result<WalletFile> { @@ -266,24 +294,29 @@ enum WalletFileMode { Replace, } -fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> { - let wallet = Wallet::from_seed(&seed); +fn write_wallet(path: &Path, seed: SecretString, mode: WalletFileMode) -> Result<Wallet> { + let wallet = Wallet::from_seed(seed.expose_secret()); write_wallet_file(path, seed, wallet.address(), mode)?; Ok(wallet) } fn write_wallet_encrypted( path: &Path, - seed: String, + seed: SecretString, password: &str, mode: WalletFileMode, ) -> Result<Wallet> { - let wallet = Wallet::from_seed(&seed); + let wallet = Wallet::from_seed(seed.expose_secret()); write_encrypted_wallet_file(path, seed, wallet.address(), password, mode)?; Ok(wallet) } -fn write_wallet_file(path: &Path, seed: String, address: &str, mode: WalletFileMode) -> Result<()> { +fn write_wallet_file( + path: &Path, + seed: SecretString, + address: &str, + mode: WalletFileMode, +) -> Result<()> { write_wallet_data_file(path, WalletData { seed }, address, mode) } @@ -302,12 +335,13 @@ fn write_wallet_data_file( let mut bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; bytes.push(b'\n'); - atomic_write_wallet_file(path, &bytes, mode) + let bytes = SecretBox::new(Box::new(bytes)); + atomic_write_wallet_file(path, bytes.expose_secret(), mode) } fn write_encrypted_wallet_file( path: &Path, - seed: String, + seed: SecretString, address: &str, password: &str, mode: WalletFileMode, @@ -355,14 +389,15 @@ fn encrypt_wallet_data( let salt = random_bytes::<16>()?; let nonce = random_bytes::<12>()?; let key = wallet_encryption_key(password, &salt, WALLET_ENCRYPTION_ITERATIONS); - let cipher = ChaCha20Poly1305::new((&key).into()); - let plaintext = - serde_json::to_vec(data).context("failed to serialize encrypted wallet data")?; + let cipher = ChaCha20Poly1305::new(key.expose_secret().into()); + let plaintext = SecretBox::new(Box::new( + serde_json::to_vec(data).context("failed to serialize encrypted wallet data")?, + )); let ciphertext = cipher .encrypt( Nonce::from_slice(&nonce), Payload { - msg: &plaintext, + msg: plaintext.expose_secret(), aad: address.as_bytes(), }, ) @@ -377,7 +412,11 @@ fn encrypt_wallet_data( }) } -fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) -> Result<String> { +fn decrypt_seed( + encryption: &EncryptedWalletSeed, + address: &str, + password: &str, +) -> Result<SecretString> { Ok(decrypt_wallet_data(encryption, address, password)?.seed) } @@ -403,28 +442,32 @@ fn decrypt_wallet_data( bail!("invalid wallet encryption nonce length"); } let key = wallet_encryption_key(password, &salt, encryption.kdf_iterations); - let cipher = ChaCha20Poly1305::new((&key).into()); - let plaintext = cipher - .decrypt( - Nonce::from_slice(&nonce), - Payload { - msg: &ciphertext, - aad: address.as_bytes(), - }, - ) - .map_err(|_| anyhow!("invalid wallet password"))?; - match serde_json::from_slice::<WalletData>(&plaintext) { + let cipher = ChaCha20Poly1305::new(key.expose_secret().into()); + let plaintext = SecretBox::new(Box::new( + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: address.as_bytes(), + }, + ) + .map_err(|_| anyhow!("invalid wallet password"))?, + )); + match serde_json::from_slice::<WalletData>(plaintext.expose_secret()) { Ok(data) => Ok(data), Err(_) => Ok(WalletData { - seed: String::from_utf8(plaintext).context("wallet seed is not valid utf-8")?, + seed: String::from_utf8(plaintext.expose_secret().to_vec()) + .context("wallet seed is not valid utf-8")? + .into(), }), } } -fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] { - let mut key = [0_u8; 32]; - pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut key); - key +fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> SecretBox<[u8; 32]> { + SecretBox::init_with_mut(|key: &mut [u8; 32]| { + pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, key); + }) } fn validate_wallet_encryption_iterations(iterations: u32) -> Result<()> { @@ -442,13 +485,19 @@ fn random_bytes<const N: usize>() -> Result<[u8; N]> { Ok(bytes) } -fn generate_seed_phrase() -> Result<String> { - let mut entropy = [0_u8; BIP39_SEED_ENTROPY_BYTES]; - getrandom::getrandom(&mut entropy) - .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?; - let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy) +fn generate_seed_phrase() -> Result<SecretString> { + let mut random_error = None; + let entropy = SecretBox::init_with_mut(|entropy: &mut [u8; BIP39_SEED_ENTROPY_BYTES]| { + if let Err(error) = getrandom::getrandom(entropy) { + random_error = Some(error); + } + }); + if let Some(error) = random_error { + return Err(anyhow!("failed to read system randomness: {error:?}")); + } + let mnemonic = Mnemonic::from_entropy_in(Language::English, entropy.expose_secret()) .context("failed to generate BIP-39 seed phrase")?; - Ok(mnemonic.to_string()) + Ok(mnemonic.to_string().into()) } fn hex_encode(bytes: impl AsRef<[u8]>) -> String { @@ -522,31 +571,37 @@ fn validate_wallet_file_metadata(stored: &WalletFile) -> Result<()> { } else { let seed = stored .seed - .as_deref() + .as_ref() + .map(ExposeSecret::expose_secret) .context("wallet file does not contain a seed")?; let _ = normalize_seed_phrase(seed)?; } Ok(()) } -fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> { - let normalized = seed_phrase - .split_whitespace() - .map(|word| word.trim().to_ascii_lowercase()) - .filter(|word| !word.is_empty()) - .collect::<Vec<_>>() - .join(" "); - if normalized.split_whitespace().count() != GENERATED_SEED_WORDS { +fn normalize_seed_phrase(seed_phrase: &str) -> Result<SecretString> { + let mut normalized = String::new(); + for word in seed_phrase.split_whitespace().map(str::trim) { + if word.is_empty() { + continue; + } + if !normalized.is_empty() { + normalized.push(' '); + } + normalized.extend(word.chars().map(|character| character.to_ascii_lowercase())); + } + let normalized: SecretString = normalized.into(); + if normalized.expose_secret().split_whitespace().count() != GENERATED_SEED_WORDS { bail!("seed phrase must contain 24 words"); } - for word in normalized.split_whitespace() { + for word in normalized.expose_secret().split_whitespace() { if !word.chars().all(|ch| ch.is_ascii_lowercase()) { bail!("seed phrase words must contain only letters"); } } - let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized) + let mnemonic = Mnemonic::parse_in_normalized(Language::English, normalized.expose_secret()) .context("invalid BIP-39 seed phrase")?; - Ok(mnemonic.to_string()) + Ok(mnemonic.to_string().into()) } fn atomic_write_wallet_file(path: &Path, bytes: &[u8], mode: WalletFileMode) -> Result<()> { diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -1,28 +1,37 @@ +use std::{fmt, sync::Arc}; + use ed25519_dalek::{Signature, Signer, SigningKey}; +use secrecy::{ExposeSecret, SecretBox, zeroize::Zeroize}; use sha2::{Digest, Sha256}; use super::block::LeaderProofPayload; -use super::{ - BurnBundle, BurnBundlePayload, LeaderProof, PUBLIC_KEY_BYTES, decode_hex_array, hex_encode, -}; +use super::{BurnBundle, BurnBundlePayload, LeaderProof, hex_encode}; const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone)] pub struct Wallet { address: String, - secret: String, + signing_seed: Arc<SecretBox<[u8; 32]>>, } impl Wallet { pub fn from_seed(seed: &str) -> Self { - let seed_hash = Sha256::digest(format!("{WALLET_SEED_DOMAIN}:{seed}").as_bytes()); - let mut signing_seed = [0_u8; 32]; - signing_seed.copy_from_slice(&seed_hash); - let signing_key = SigningKey::from_bytes(&signing_seed); - let secret = hex_encode(signing_seed); + let mut hasher = Sha256::new(); + hasher.update(WALLET_SEED_DOMAIN.as_bytes()); + hasher.update(b":"); + hasher.update(seed.as_bytes()); + let mut seed_hash = hasher.finalize(); + let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| { + signing_seed.copy_from_slice(&seed_hash); + }); + seed_hash.zeroize(); + let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); let address = hex_encode(signing_key.verifying_key().to_bytes()); - Self { address, secret } + Self { + address, + signing_seed: Arc::new(signing_seed), + } } pub fn address(&self) -> &str { @@ -34,9 +43,7 @@ impl Wallet { } pub(super) fn sign_bytes(&self, payload: &[u8]) -> String { - let seed = - decode_hex_array::<PUBLIC_KEY_BYTES>(&self.secret).expect("wallet secret is valid hex"); - let signing_key = SigningKey::from_bytes(&seed); + let signing_key = SigningKey::from_bytes(self.signing_seed.expose_secret()); let signature: Signature = signing_key.sign(payload); hex_encode(signature.to_bytes()) } @@ -62,3 +69,39 @@ impl Wallet { } } } + +impl fmt::Debug for Wallet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Wallet") + .field("address", &self.address) + .field("signing_seed", &"[REDACTED]") + .finish() + } +} + +impl PartialEq for Wallet { + fn eq(&self, other: &Self) -> bool { + self.address == other.address + } +} + +impl Eq for Wallet {} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + + use super::Wallet; + use crate::domain::hex_encode; + + #[test] + fn debug_output_redacts_the_wallet_signing_seed() { + let wallet = Wallet::from_seed("debug-redaction-wallet-seed"); + let signing_seed = hex_encode(wallet.signing_seed.expose_secret()); + let debug = format!("{wallet:?}"); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(&signing_seed)); + } +} diff --git a/src/main.rs b/src/main.rs @@ -25,6 +25,7 @@ use iuna::{ run_vdf_cancellable_with_progress, }, }; +use secrecy::{ExposeSecret, SecretString}; use tokio::sync::Mutex; mod cli; @@ -144,9 +145,16 @@ async fn main() -> Result<()> { let auth_config_dirty = apply_startup_wallet_password_config( &config_path, &mut ui_config, - startup_wallet_password.as_deref(), + startup_wallet_password + .as_ref() + .map(ExposeSecret::expose_secret), + )?; + let wallet_load = load_startup_wallet( + &wallet_path, + startup_wallet_password + .as_ref() + .map(ExposeSecret::expose_secret), )?; - let wallet_load = load_startup_wallet(&wallet_path, startup_wallet_password.as_deref())?; let wallet_address = wallet_load.address().to_string(); let ui_config_dirty = opts.chain_mode == ChainMode::Genesis || p2p_config_dirty @@ -387,7 +395,7 @@ fn load_startup_wallet( } } -fn startup_wallet_password_from_env() -> Result<Option<String>> { +fn startup_wallet_password_from_env() -> Result<Option<SecretString>> { let Some(password) = std::env::var_os(WALLET_PASSWORD_ENV) else { return Ok(None); }; @@ -396,7 +404,7 @@ fn startup_wallet_password_from_env() -> Result<Option<String>> { .map_err(|_| anyhow::anyhow!("{WALLET_PASSWORD_ENV} must be valid UTF-8"))?; http::validate_management_password(&password) .with_context(|| format!("{WALLET_PASSWORD_ENV} is not a valid wallet password"))?; - Ok(Some(password)) + Ok(Some(password.into())) } fn startup_bool_from_env(name: &str) -> Result<Option<bool>> {