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:
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, ¤t_hash)? {
+ if !verify_password(exposed_old_password, ¤t_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>> {