From adcc3fddc7e2bc9a0ee70435c71560516a86f959 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 18 Mar 2026 22:41:23 +0000 Subject: [PATCH] =?UTF-8?q?security:=20migrate=20bcrypt=E2=86=92Argon2id,?= =?UTF-8?q?=20random=20Bitcoin=20RPC=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Password hashing migrated from bcrypt to Argon2id (m=64MiB, t=3, p=4). Transparent upgrade: on successful bcrypt login, re-hashes with Argon2id and persists. New signups and password changes use Argon2id directly. Unifies crypto stack — Argon2id was already used for TOTP and backup KDF. Bitcoin RPC password: no longer falls back to hardcoded "archipelago123". On first boot, generates a random 32-char hex password from CSPRNG, saves to /var/lib/archipelago/secrets/bitcoin-rpc-password with 0600 permissions. Existing installs with secrets file are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/auth.rs | 58 ++++++++++++++++++++++++----- core/archipelago/src/bitcoin_rpc.rs | 29 +++++++++++++-- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index f8c02005..e48c0f4b 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -110,9 +110,7 @@ impl AuthManager { } pub async fn setup_user(&self, password: &str) -> Result<()> { - use bcrypt::{hash, DEFAULT_COST}; - - let password_hash = hash(password, DEFAULT_COST)?; + let password_hash = argon2id_hash(password)?; // If onboarding was already completed (before setup), preserve that let onboarding_complete = self.is_onboarding_complete().await?; @@ -222,10 +220,25 @@ impl AuthManager { } pub async fn verify_password(&self, password: &str) -> Result { - use bcrypt::verify; - if let Some(user) = self.get_user().await? { - Ok(verify(password, &user.password_hash)?) + // Detect hash format and verify accordingly + if user.password_hash.starts_with("$2") { + // Legacy bcrypt hash — verify then auto-upgrade to Argon2id + let valid = bcrypt::verify(password, &user.password_hash)?; + if valid { + // Transparent upgrade: re-hash with Argon2id on successful login + let new_hash = argon2id_hash(password)?; + let mut upgraded = user.clone(); + upgraded.password_hash = new_hash; + let user_file = self.data_dir.join("user.json"); + fs::write(&user_file, serde_json::to_string_pretty(&upgraded)?).await?; + tracing::info!("Upgraded password hash from bcrypt to Argon2id"); + } + Ok(valid) + } else { + // Argon2id hash (PHC string format: $argon2id$...) + Ok(argon2id_verify(password, &user.password_hash)) + } } else { Ok(false) } @@ -239,15 +252,13 @@ impl AuthManager { new_password: &str, also_change_ssh: bool, ) -> Result<()> { - use bcrypt::{hash, DEFAULT_COST}; - if !self.verify_password(current_password).await? { anyhow::bail!("Current password is incorrect"); } validate_password_strength(new_password)?; - let password_hash = hash(new_password, DEFAULT_COST)?; + let password_hash = argon2id_hash(new_password)?; let mut user = self .get_user() @@ -427,3 +438,32 @@ async fn change_ssh_password(new_password: &str) -> Result<()> { tracing::info!("SSH password updated for user {}", ssh_user); Ok(()) } + +/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant). +/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage. +fn argon2id_hash(password: &str) -> Result { + use argon2::{Argon2, Params, PasswordHasher}; + use argon2::password_hash::SaltString; + use rand::rngs::OsRng; + + let salt = SaltString::generate(&mut OsRng); + let params = Params::new(65536, 3, 4, Some(32)) + .map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?; + let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let hash = hasher + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?; + Ok(hash.to_string()) +} + +/// Verify a password against an Argon2id PHC string hash. +fn argon2id_verify(password: &str, hash: &str) -> bool { + use argon2::{Argon2, PasswordVerifier}; + use argon2::password_hash::PasswordHash; + + let parsed = match PasswordHash::new(hash) { + Ok(h) => h, + Err(_) => return false, + }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} diff --git a/core/archipelago/src/bitcoin_rpc.rs b/core/archipelago/src/bitcoin_rpc.rs index 594bb158..4083f8fc 100644 --- a/core/archipelago/src/bitcoin_rpc.rs +++ b/core/archipelago/src/bitcoin_rpc.rs @@ -29,9 +29,32 @@ async fn read_password() -> String { } } - // 3. Dev fallback (will only work on dev machines with default config) - debug!("Bitcoin RPC password: using dev fallback"); - "archipelago123".to_string() + // 3. Generate a random password and persist it (first-boot provisioning) + let random_pass = generate_random_password(); + if let Some(parent) = std::path::Path::new(SECRETS_PATH).parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + match tokio::fs::write(SECRETS_PATH, &random_pass).await { + Ok(_) => { + // Restrict permissions to owner-only + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(SECRETS_PATH, std::fs::Permissions::from_mode(0o600)); + } + debug!("Bitcoin RPC password: generated and saved to secrets file"); + } + Err(e) => { + tracing::warn!("Failed to save generated Bitcoin RPC password: {} — using ephemeral", e); + } + } + random_pass +} + +/// Generate a cryptographically random password for Bitcoin RPC (32 hex chars). +fn generate_random_password() -> String { + let bytes: [u8; 16] = rand::random(); + hex::encode(bytes) } /// Get Bitcoin RPC credentials (user, password). Cached after first call.