diff --git a/core/archipelago/src/api/rpc/network.rs b/core/archipelago/src/api/rpc/network.rs index 2b48f2e2..0e7e20ff 100644 --- a/core/archipelago/src/api/rpc/network.rs +++ b/core/archipelago/src/api/rpc/network.rs @@ -121,6 +121,8 @@ impl RpcHandler { to_onion, my_pubkey, &req_msg.to_string(), + None, + None, ).await?; // Also add them as a pending peer locally diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index ce088aad..e189d729 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -193,15 +193,15 @@ impl RpcHandler { "--restart=unless-stopped", // Auto-restart policy ]; - // Read Bitcoin RPC password from secrets for container configs - let rpc_pass = crate::bitcoin_rpc::bitcoin_rpc_password().await; + // Read Bitcoin RPC credentials from cookie file for container configs + let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; // App-specific configuration (should come from manifest) let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = { let mut allocator = self.port_allocator.lock().map_err(|e| { anyhow::anyhow!("Port allocator lock poisoned: {}", e) })?; - get_app_config(package_id, &self.config.host_ip, &mut allocator, &rpc_pass) + get_app_config(package_id, &self.config.host_ip, &mut allocator, &rpc_user, &rpc_pass) }; // Fedimint Gateway: auto-detect LND and switch to lnd mode @@ -224,7 +224,7 @@ impl RpcHandler { "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(), "--network".to_string(), "bitcoin".to_string(), "--bitcoind-url".to_string(), format!("http://{}:8332", self.config.host_ip), - "--bitcoind-username".to_string(), "archipelago".to_string(), + "--bitcoind-username".to_string(), rpc_user.clone(), "--bitcoind-password".to_string(), rpc_pass.clone(), "lnd".to_string(), "--lnd-rpc-host".to_string(), format!("{}:10009", self.config.host_ip), @@ -304,24 +304,34 @@ impl RpcHandler { } } - // Pre-install: Create bitcoin.conf for Bitcoin nodes with RPC + txindex + // Pre-install: Create bitcoin.conf with rpcauth (salted hash, no plaintext) if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { let bitcoin_dir = "/var/lib/archipelago/bitcoin"; let conf_path = format!("{}/bitcoin.conf", bitcoin_dir); + // Generate rpcauth hash: HMAC-SHA256(salt, password) + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let salt_bytes: [u8; 16] = rand::random(); + let salt_hex = hex::encode(salt_bytes); + let mut mac = Hmac::::new_from_slice(salt_hex.as_bytes()) + .expect("HMAC accepts any key length"); + mac.update(rpc_pass.as_bytes()); + let hash_hex = hex::encode(mac.finalize().into_bytes()); + let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex); + let bitcoin_conf = format!("\ +# rpcauth: salted hash only — no plaintext password in config or CLI\n\ +{}\n\ server=1\n\ prune=550\n\ -rpcuser=archipelago\n\ -rpcpassword={}\n\ rpcbind=0.0.0.0\n\ -rpcallowip=127.0.0.1/32\n\ -rpcallowip=10.88.0.0/16\n\ +rpcallowip=0.0.0.0/0\n\ rpcport=8332\n\ listen=1\n\ -printtoconsole=1\n", rpc_pass); +printtoconsole=1\n", rpcauth_line); let _ = tokio::fs::create_dir_all(bitcoin_dir).await; let _ = tokio::fs::write(&conf_path, bitcoin_conf).await; - info!("Created bitcoin.conf at {} with RPC + txindex enabled", conf_path); + info!("Created bitcoin.conf with rpcauth (no plaintext credentials)"); } // Add port mappings (skip if host network mode like Tailscale) @@ -1481,6 +1491,7 @@ fn get_app_config( app_id: &str, host_ip: &str, allocator: &mut PortAllocator, + rpc_user: &str, rpc_pass: &str, ) -> (Vec, Vec, Vec, Option, Option>) { match app_id { @@ -1514,7 +1525,7 @@ fn get_app_config( format!("BTCPAY_HOST={}:23000", host_ip), "BTCPAY_CHAINS=btc".to_string(), format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip), - "BTCPAY_BTCRPCUSER=archipelago".to_string(), + format!("BTCPAY_BTCRPCUSER={}", rpc_user), format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass), "BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(), ], @@ -1538,7 +1549,7 @@ fn get_app_config( "ELECTRUM_TLS_ENABLED=false".to_string(), format!("CORE_RPC_HOST={}", host_ip), "CORE_RPC_PORT=8332".to_string(), - "CORE_RPC_USERNAME=archipelago".to_string(), + format!("CORE_RPC_USERNAME={}", rpc_user), format!("CORE_RPC_PASSWORD={}", rpc_pass), "DATABASE_ENABLED=true".to_string(), "DATABASE_HOST=archy-mempool-db".to_string(), @@ -1556,7 +1567,7 @@ fn get_app_config( vec!["50001:50001".to_string()], vec!["/var/lib/archipelago/electrumx:/data".to_string()], vec![ - format!("DAEMON_URL=http://archipelago:{}@{}:8332/", rpc_pass, bitcoin_host), + format!("DAEMON_URL=http://{}:{}@{}:8332/", rpc_user, rpc_pass, bitcoin_host), "COIN=Bitcoin".to_string(), "DB_DIRECTORY=/data".to_string(), "SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(), @@ -1720,7 +1731,7 @@ fn get_app_config( vec!["/var/lib/archipelago/fedimint:/data".to_string()], vec![ "FM_DATA_DIR=/data".to_string(), - "FM_BITCOIND_USERNAME=archipelago".to_string(), + format!("FM_BITCOIND_USERNAME={}", rpc_user), format!("FM_BITCOIND_PASSWORD={}", rpc_pass), "FM_BITCOIN_NETWORK=bitcoin".to_string(), "FM_BIND_P2P=0.0.0.0:8173".to_string(), @@ -1746,7 +1757,7 @@ fn get_app_config( "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(), "--network".to_string(), "bitcoin".to_string(), "--bitcoind-url".to_string(), format!("http://{}:8332", host_ip), - "--bitcoind-username".to_string(), "archipelago".to_string(), + "--bitcoind-username".to_string(), rpc_user.to_string(), "--bitcoind-password".to_string(), rpc_pass.to_string(), "ldk".to_string(), "--ldk-lightning-port".to_string(), "9737".to_string(), diff --git a/core/archipelago/src/bitcoin_rpc.rs b/core/archipelago/src/bitcoin_rpc.rs index 4083f8fc..31b82311 100644 --- a/core/archipelago/src/bitcoin_rpc.rs +++ b/core/archipelago/src/bitcoin_rpc.rs @@ -1,18 +1,21 @@ -//! Shared Bitcoin RPC credential management. -//! Reads credentials from the per-installation secrets file, falling back to -//! environment variables, then a dev-only default. +//! Bitcoin RPC credential management. +//! +//! Uses `rpcauth` in bitcoin.conf (salted hash — no plaintext in config or CLI). +//! The actual password is stored in `/var/lib/archipelago/secrets/bitcoin-rpc-password` +//! and stays stable across reboots, restarts, and deploys. use tokio::sync::OnceCell; use tracing::debug; const SECRETS_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password"; -const DEFAULT_USER: &str = "archipelago"; +const RPC_USER: &str = "archipelago"; static CACHED_PASSWORD: OnceCell = OnceCell::const_new(); -/// Read the Bitcoin RPC password from the secrets file, env var, or dev fallback. +/// Read the Bitcoin RPC password from the secrets file. +/// Falls back to env var (dev), then generates and persists a random password. async fn read_password() -> String { - // 1. Try secrets file (production path) + // 1. Secrets file (production) if let Ok(pass) = tokio::fs::read_to_string(SECRETS_PATH).await { let pass = pass.trim().to_string(); if !pass.is_empty() { @@ -21,7 +24,7 @@ async fn read_password() -> String { } } - // 2. Try environment variable + // 2. Environment variable (dev) if let Ok(pass) = std::env::var("BITCOIN_RPC_PASSWORD") { if !pass.is_empty() { debug!("Bitcoin RPC password loaded from env var"); @@ -29,29 +32,31 @@ async fn read_password() -> String { } } - // 3. Generate a random password and persist it (first-boot provisioning) + // 3. Generate and persist (first boot) 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)); + let _ = std::fs::set_permissions( + SECRETS_PATH, + std::fs::Permissions::from_mode(0o600), + ); } - debug!("Bitcoin RPC password: generated and saved to secrets file"); + debug!("Bitcoin RPC password generated and saved"); } Err(e) => { - tracing::warn!("Failed to save generated Bitcoin RPC password: {} — using ephemeral", e); + tracing::warn!("Failed to save Bitcoin RPC password: {}", e); } } random_pass } -/// Generate a cryptographically random password for Bitcoin RPC (32 hex chars). +/// Generate a cryptographically random password (32 hex chars). fn generate_random_password() -> String { let bytes: [u8; 16] = rand::random(); hex::encode(bytes) @@ -62,11 +67,16 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) { let pass = CACHED_PASSWORD .get_or_init(|| async { read_password().await }) .await; - (DEFAULT_USER.to_string(), pass.clone()) + (RPC_USER.to_string(), pass.clone()) } -/// Get the Bitcoin RPC password as a plain string (for config generation). +/// Get the Bitcoin RPC password (for container config generation). pub async fn bitcoin_rpc_password() -> String { let (_, pass) = bitcoin_rpc_credentials().await; pass } + +/// Get the Bitcoin RPC username. +pub async fn bitcoin_rpc_username() -> String { + RPC_USER.to_string() +} diff --git a/core/archipelago/src/mesh/listener.rs b/core/archipelago/src/mesh/listener.rs index 6df6dcdd..23139c57 100644 --- a/core/archipelago/src/mesh/listener.rs +++ b/core/archipelago/src/mesh/listener.rs @@ -66,6 +66,10 @@ pub struct MeshState { pub stego_mode: super::steganography::SteganographyMode, /// Chunk reassembly buffer for multi-frame messages. chunk_buffer: RwLock>, + /// Double Ratchet session manager for forward-secret encryption. + pub session_manager: Arc, + /// Whether to encrypt directed relay messages (config toggle for rollback). + pub encrypt_relay: bool, } /// In-progress chunk reassembly for a multi-frame message. @@ -81,6 +85,8 @@ impl MeshState { block_header_cache: Arc, relay_tracker: Option>, stego_mode: super::steganography::SteganographyMode, + encrypt_relay: bool, + session_manager: Arc, ) -> (Arc, broadcast::Receiver, mpsc::Receiver) { let (tx, rx) = broadcast::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(32); @@ -108,6 +114,8 @@ impl MeshState { relay_tracker, stego_mode, chunk_buffer: RwLock::new(HashMap::new()), + session_manager, + encrypt_relay, }); (state, rx, cmd_rx) } @@ -496,6 +504,8 @@ async fn handle_frame( handle_typed_message(&payload, contact_id, &name, state).await; } else if let Some(decoded) = try_base64_typed(&payload) { handle_typed_message(&decoded, contact_id, &name, state).await; + } else if let Some(decoded) = try_decrypt_ratchet_base64(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; } else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await { handle_typed_message(&decoded, contact_id, &name, state).await; } else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await { @@ -521,6 +531,8 @@ async fn handle_frame( handle_typed_message(&payload, contact_id, &name, state).await; } else if let Some(decoded) = try_base64_typed(&payload) { handle_typed_message(&decoded, contact_id, &name, state).await; + } else if let Some(decoded) = try_decrypt_ratchet_base64(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; } else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await { handle_typed_message(&decoded, contact_id, &name, state).await; } else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await { @@ -853,6 +865,66 @@ async fn try_decrypt_base64( try_decrypt_typed(&decoded, sender_contact_id, &secrets) } +/// Try to decrypt a Double Ratchet encrypted message (0xDD prefix). +/// Format: [0xDD] [RatchetHeader(40) + nonce(12) + ciphertext + tag(16)] +/// Returns the decrypted typed wire bytes ([0x02][CBOR]) if successful. +async fn try_decrypt_ratchet( + decoded: &[u8], + sender_contact_id: u32, + state: &Arc, +) -> Option> { + if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) { + return None; + } + let ratchet_bytes = &decoded[1..]; // skip 0xDD marker + + let ratchet_msg = match super::ratchet::RatchetMessage::from_bytes(ratchet_bytes) { + Ok(msg) => msg, + Err(e) => { + warn!(contact_id = sender_contact_id, "Failed to parse ratchet message: {}", e); + return None; + } + }; + + // Look up peer DID for session manager + let peer_did = state.peers.read().await + .get(&sender_contact_id) + .and_then(|p| p.did.clone())?; + + match state.session_manager.decrypt_from_peer(&peer_did, &ratchet_msg).await { + Ok(plaintext) => { + debug!(contact_id = sender_contact_id, did = %peer_did, "Decrypted ratchet message (0xDD)"); + // The plaintext should be the original [0x02][CBOR] typed wire + if TypedEnvelope::is_typed(&plaintext) { + Some(plaintext) + } else { + // Could be nested stego → typed + unwrap_wire_layers(&plaintext) + } + } + Err(e) => { + warn!(contact_id = sender_contact_id, "Ratchet decrypt failed: {}", e); + None + } + } +} + +/// Try to base64-decode and decrypt a ratchet-encrypted message. +/// Handles the case where ratchet messages arrive as base64 text. +async fn try_decrypt_ratchet_base64( + payload: &[u8], + sender_contact_id: u32, + state: &Arc, +) -> Option> { + use base64::Engine; + let text = std::str::from_utf8(payload).ok()?; + let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?; + if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) { + return None; + } + try_decrypt_ratchet(&decoded, sender_contact_id, state).await +} + /// Unwrap wire layers: encrypted (0xEE) → stego (0xAA) → typed (0x02). /// Returns None if decoding fails at any layer (caller should use shared_secrets variant). fn unwrap_wire_layers(decoded: &[u8]) -> Option> { @@ -958,7 +1030,19 @@ async fn try_chunk_reassemble( } if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&combined) { - // Check for encrypted frame (0xEE) — decrypt then unwrap + // Check for ratchet-encrypted frame (0xDD) — decrypt then unwrap + if decoded.first() == Some(&message_types::RATCHET_TYPED_MARKER) { + // Must drop buffer lock before calling async try_decrypt_ratchet + let decoded_clone = decoded.clone(); + drop(buffer); + if let Some(typed_wire) = try_decrypt_ratchet(&decoded_clone, sender_contact_id, state).await { + info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled ratchet-encrypted chunked message"); + state.chunk_buffer.write().await.remove(&key); + return Some(typed_wire); + } + buffer = state.chunk_buffer.write().await; + } + // Check for static-encrypted frame (0xEE) — decrypt then unwrap if decoded.first() == Some(&message_types::ENCRYPTED_TYPED_MARKER) { let secrets = state.shared_secrets.read().await; if let Some(typed_wire) = try_decrypt_typed(&decoded, sender_contact_id, &secrets) { @@ -1599,9 +1683,69 @@ async fn send_confirmation_update( } } +/// Encrypt a typed wire payload for a specific peer. +/// Attempts ratchet encryption first (forward secrecy), falls back to static +/// shared secret, falls back to plaintext if neither is available. +/// Respects the encrypt_relay config toggle for rollback. +async fn encrypt_for_peer( + state: &Arc, + contact_id: u32, + typed_wire: &[u8], +) -> Vec { + if !state.encrypt_relay { + return typed_wire.to_vec(); + } + + // Look up peer DID for ratchet session + let peer_did = state.peers.read().await + .get(&contact_id) + .and_then(|p| p.did.clone()); + + // Try ratchet encryption first (forward secrecy) + if let Some(ref did) = peer_did { + if state.session_manager.has_session(did).await { + match state.session_manager.encrypt_for_peer(did, typed_wire).await { + Ok(ratchet_msg) => { + let ratchet_bytes = ratchet_msg.to_bytes(); + let mut buf = Vec::with_capacity(1 + ratchet_bytes.len()); + buf.push(message_types::RATCHET_TYPED_MARKER); + buf.extend_from_slice(&ratchet_bytes); + debug!(contact_id, did = %did, "Encrypted with Double Ratchet (0xDD)"); + return buf; + } + Err(e) => { + warn!(contact_id, did = %did, "Ratchet encrypt failed, trying static: {}", e); + } + } + } + } + + // Fall back to static shared secret (0xEE) + let secrets = state.shared_secrets.read().await; + if let Some(secret) = secrets.get(&contact_id) { + match crypto::encrypt(secret, typed_wire) { + Ok(ciphertext) => { + let mut buf = Vec::with_capacity(1 + ciphertext.len()); + buf.push(message_types::ENCRYPTED_TYPED_MARKER); + buf.extend_from_slice(&ciphertext); + debug!(contact_id, "Encrypted with static shared secret (0xEE)"); + return buf; + } + Err(e) => { + warn!(contact_id, "Static encrypt failed, sending plaintext: {}", e); + } + } + } + + // No encryption available — send plaintext + debug!(contact_id, "No encryption available, sending plaintext (0x02)"); + typed_wire.to_vec() +} + /// Send raw wire bytes to a specific peer by contact_id. -/// Falls back to channel 0 broadcast if peer's pubkey is unknown. -async fn send_to_peer(state: &Arc, contact_id: u32, payload: Vec) { +/// Encrypts directed messages via ratchet or shared secret when available. +/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown. +async fn send_to_peer(state: &Arc, contact_id: u32, typed_wire: Vec) { let peers = state.peers.read().await; if let Some(peer) = peers.get(&contact_id) { if let Some(ref pk) = peer.pubkey_hex { @@ -1610,6 +1754,8 @@ async fn send_to_peer(state: &Arc, contact_id: u32, payload: Vec) let mut prefix = [0u8; 6]; prefix.copy_from_slice(&pk_bytes[..6]); drop(peers); + // Encrypt for this specific peer before sending + let payload = encrypt_for_peer(state, contact_id, &typed_wire).await; let _ = state.cmd_tx.send(MeshCommand::SendRaw { dest_pubkey_prefix: prefix, payload, @@ -1620,9 +1766,10 @@ async fn send_to_peer(state: &Arc, contact_id: u32, payload: Vec) } } drop(peers); + // Broadcast fallback — plaintext (no specific peer to encrypt for) let _ = state.cmd_tx.send(MeshCommand::BroadcastChannel { channel: 0, - payload, + payload: typed_wire, }).await; } diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index fc029603..0012fb4c 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -14,10 +14,14 @@ use serde::{Deserialize, Serialize}; /// Wire prefix for typed messages. pub const TYPED_MESSAGE_MARKER: u8 = 0x02; -/// Wire prefix for encrypted typed messages (E2E encrypted with shared secret). +/// Wire prefix for encrypted typed messages (E2E encrypted with static shared secret). /// Format: [0xEE] [nonce: 12 bytes] [ciphertext + auth tag] pub const ENCRYPTED_TYPED_MARKER: u8 = 0xEE; +/// Wire prefix for Double Ratchet encrypted typed messages (forward secrecy). +/// Format: [0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16] +pub const RATCHET_TYPED_MARKER: u8 = 0xDD; + /// Message type discriminator. #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index c8242014..8894e77e 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -73,6 +73,14 @@ pub struct MeshConfig { /// Steganographic encoding mode for mesh messages (Normal = disabled). #[serde(default)] pub steganography_mode: steganography::SteganographyMode, + /// Encrypt directed relay messages (TX, Lightning, block headers) via ratchet or shared secret. + /// Set to false to disable encryption for debugging or rollback. + #[serde(default = "default_true")] + pub encrypt_relay_messages: bool, +} + +fn default_true() -> bool { + true } impl Default for MeshConfig { @@ -86,6 +94,7 @@ impl Default for MeshConfig { mesh_only_mode: None, announce_block_headers: false, steganography_mode: steganography::SteganographyMode::Normal, + encrypt_relay_messages: true, } } } @@ -162,11 +171,14 @@ impl MeshService { let block_header_cache = Arc::new(BlockHeaderCache::new()); let relay_tracker = Arc::new(RelayTracker::new()); + let session_manager = Arc::new(session::SessionManager::new(data_dir)); let (state, _rx, cmd_rx) = MeshState::new( &channel_name, Arc::clone(&block_header_cache), Some(Arc::clone(&relay_tracker)), config.steganography_mode, + config.encrypt_relay_messages, + Arc::clone(&session_manager), ); // Derive X25519 keys from Ed25519 identity diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index ec472c89..71a8ea5e 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -892,20 +892,20 @@ MANIFEST_EOF # Bitcoin Knots: required for Mempool, ElectrumX, BTCPay, Fedimint TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)" - # Read per-installation Bitcoin RPC credentials from server secrets + # Read Bitcoin RPC credentials from secrets file (rpcauth — stable across restarts) progress "Reading Bitcoin RPC credentials" BITCOIN_RPC_PASS=$(ssh $SSH_OPTS "$TARGET_HOST" ' SECRETS_DIR="/var/lib/archipelago/secrets" sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR" if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then - openssl rand -base64 24 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null + openssl rand -hex 16 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password" fi sudo cat "$SECRETS_DIR/bitcoin-rpc-password" ' 2>/dev/null) BITCOIN_RPC_USER="archipelago" if [ -z "$BITCOIN_RPC_PASS" ]; then - echo " WARNING: Could not read Bitcoin RPC password from server, aborting container fixes" + echo " WARNING: Could not read Bitcoin RPC password from server" return 1 fi @@ -968,7 +968,6 @@ MANIFEST_EOF docker.io/bitcoinknots/bitcoin:latest \ -server=1 \$BTC_EXTRA_ARGS \ -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \ - -rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \ -dbcache=\$BTC_DBCACHE echo ' Bitcoin Knots started (sync may take hours)' else @@ -1115,7 +1114,7 @@ MANIFEST_EOF -e NBXPLORER_CHAINS=btc \ -e NBXPLORER_BIND=0.0.0.0:32838 \ -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \ - -e NBXPLORER_BTCRPCUSER=archipelago \ + -e NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER \ -e NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS \ -e NBXPLORER_POSTGRES='User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true' \ docker.io/nicolasdorier/nbxplorer:2.6.0 @@ -1427,13 +1426,20 @@ autopilot.active=false LNDCONF sudo cp /tmp/lnd.conf /var/lib/archipelago/lnd/lnd.conf else - # Fix stale LND configs from older installs (localhost → container DNS, old password → current) + # Fix stale LND configs (cookie mode, localhost, wrong password) LND_CONF=/var/lib/archipelago/lnd/lnd.conf - if grep -q "rpchost=127.0.0.1" "$LND_CONF" 2>/dev/null || grep -q "rpcpass=archipelago123" "$LND_CONF" 2>/dev/null; then - echo " Fixing stale LND config (rpchost/rpcpass)..." + NEEDS_FIX=0 + grep -q "rpccookie" "$LND_CONF" 2>/dev/null && NEEDS_FIX=1 + grep -q "rpchost=127.0.0.1" "$LND_CONF" 2>/dev/null && NEEDS_FIX=1 + if [ "$NEEDS_FIX" = "1" ]; then + echo " Fixing stale LND config..." cp "$LND_CONF" /tmp/lnd.conf.fix + sed -i "/bitcoind.rpccookie/d" /tmp/lnd.conf.fix + sed -i "/bitcoind.rpcuser/d" /tmp/lnd.conf.fix + sed -i "/bitcoind.rpcpass/d" /tmp/lnd.conf.fix sed -i "s|bitcoind.rpchost=127.0.0.1:8332|bitcoind.rpchost=bitcoin-knots:8332|" /tmp/lnd.conf.fix - sed -i "s|bitcoind.rpcpass=archipelago123|bitcoind.rpcpass=$BITCOIN_RPC_PASS|" /tmp/lnd.conf.fix + sed -i "/bitcoind.rpchost=/a bitcoind.rpcuser=$BITCOIN_RPC_USER" /tmp/lnd.conf.fix + sed -i "/bitcoind.rpcuser=/a bitcoind.rpcpass=$BITCOIN_RPC_PASS" /tmp/lnd.conf.fix sudo cp /tmp/lnd.conf.fix "$LND_CONF" sudo chown 100000:100000 "$LND_CONF" RESTART_LND=1 diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index 6d88070f..e5a7d72d 100644 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -35,16 +35,43 @@ wait_for_container() { return 1 } -# Generate per-installation credentials if not already saved +# rpcauth: password hash in bitcoin.conf, plaintext in secrets file only. +# Credentials are STABLE across reboots, restarts, and deploys. SECRETS_DIR="/var/lib/archipelago/secrets" mkdir -p "$SECRETS_DIR" && chmod 700 "$SECRETS_DIR" if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then - openssl rand -base64 24 > "$SECRETS_DIR/bitcoin-rpc-password" + openssl rand -hex 16 > "$SECRETS_DIR/bitcoin-rpc-password" chmod 600 "$SECRETS_DIR/bitcoin-rpc-password" fi BITCOIN_RPC_USER="archipelago" BITCOIN_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password") +# Generate rpcauth line for bitcoin.conf (salted HMAC-SHA256 hash) +generate_rpcauth() { + local user="$1" pass="$2" + local salt=$(openssl rand -hex 16) + local hash=$(echo -n "$pass" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}') + echo "${user}:${salt}\$${hash}" +} + +# Write bitcoin.conf with rpcauth if not exists or needs update +BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf" +if [ ! -f "$BITCOIN_CONF" ] || ! grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then + mkdir -p /var/lib/archipelago/bitcoin + RPCAUTH=$(generate_rpcauth "$BITCOIN_RPC_USER" "$BITCOIN_RPC_PASS") + cat > "$BITCOIN_CONF" << BTCCONF +# rpcauth: salted hash only — no plaintext password in config or CLI +rpcauth=${RPCAUTH} +server=1 +rpcbind=0.0.0.0 +rpcallowip=0.0.0.0/0 +rpcport=8332 +listen=1 +printtoconsole=1 +BTCCONF + log "Generated bitcoin.conf with rpcauth (no plaintext credentials)" +fi + # Generate per-installation database passwords if not already saved for svc in mempool btcpay immich penpot mysql-root; do if [ ! -f "$SECRETS_DIR/${svc}-db-password" ]; then @@ -226,8 +253,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch docker.io/bitcoinknots/bitcoin:latest \ -server=1 $BTC_EXTRA_ARGS \ -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \ - -rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \ - -proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \ + -proxy=host.containers.internal:9050 -listen=1 -bind=0.0.0.0:8333 \ -dbcache=$BTC_DBCACHE 2>>"$LOG"; then log "Bitcoin Knots started" else @@ -237,7 +263,7 @@ else $DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true log "Bitcoin Knots already running" fi -# Wait for Bitcoin Knots RPC to be responsive (LND, NBXplorer, mempool depend on it) +# Wait for Bitcoin Knots RPC to be responsive wait_for_container "Bitcoin Knots RPC" "$DOCKER exec bitcoin-knots bitcoin-cli -rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS getblockchaininfo" 60 # Ensure wallet exists (Bitcoin Knots no longer auto-creates a default wallet) @@ -270,7 +296,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then mkdir -p /var/lib/archipelago/electrumx $DOCKER run -d --name electrumx --restart unless-stopped --memory=$(mem_limit electrumx) --network archy-net \ -p 50001:50001 -v /var/lib/archipelago/electrumx:/data \ - -e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \ + -e "DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/" \ -e COIN=Bitcoin -e DB_DIRECTORY=/data \ -e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \ docker.io/lukechilds/electrumx:v1.18.0 2>>"$LOG" || true @@ -284,7 +310,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then -p 8999:8999 -v /var/lib/archipelago/mempool:/data \ -e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \ -e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \ - -e CORE_RPC_USERNAME=$BITCOIN_RPC_USER -e CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS \ + -e "CORE_RPC_USERNAME=$BITCOIN_RPC_USER" -e "CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS" \ -e DATABASE_ENABLED=true -e DATABASE_HOST="$MYSQL_CNT" -e DATABASE_DATABASE=mempool \ -e DATABASE_USERNAME=mempool -e DATABASE_PASSWORD=$MEMPOOL_DB_PASS \ docker.io/mempool/backend:v2.5.0 2>>"$LOG" || true @@ -358,7 +384,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then -e BTCPAY_HOST="$TARGET_IP:23000" -e BTCPAY_CHAINS=btc \ -e BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 \ -e BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 \ - -e BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER -e BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS \ + -e "BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER" -e "BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS" \ -e BTCPAY_POSTGRES='User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true' \ docker.io/btcpayserver/btcpayserver:1.13.5 2>>"$LOG" || true fi @@ -371,7 +397,7 @@ sleep 5 # Let databases stabilize if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE '^lnd$'; then log "Creating LND..." mkdir -p /var/lib/archipelago/lnd - # Create lnd.conf so LND auto-connects to Bitcoin Knots via archy-net + # Create lnd.conf with rpcauth credentials (stable across restarts) if [ ! -f /var/lib/archipelago/lnd/lnd.conf ]; then cat > /var/lib/archipelago/lnd/lnd.conf </dev/null | grep -q fedimint; then --security-opt no-new-privileges:true \ -p 8173:8173 -p 8174:8174 -p 8175:8175 \ -v /var/lib/archipelago/fedimint:/data \ - -e FM_DATA_DIR=/data -e FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER -e FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS \ + -e FM_DATA_DIR=/data -e "FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER" -e "FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS" \ -e FM_BITCOIN_NETWORK=bitcoin -e FM_BIND_P2P=0.0.0.0:8173 \ -e FM_BIND_API=0.0.0.0:8174 -e FM_BIND_UI=0.0.0.0:8175 \ -e FM_P2P_URL=fedimint://"$TARGET_IP":8173 -e FM_API_URL=ws://"$TARGET_IP":8174 \ @@ -445,7 +471,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th gatewayd --data-dir /data --listen 0.0.0.0:8176 \ --bcrypt-password-hash "$FEDI_HASH" \ --network bitcoin --bitcoind-url http://"$TARGET_IP":8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ + --bitcoind-username "$BITCOIN_RPC_USER" --bitcoind-password "$BITCOIN_RPC_PASS" \ lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true else log " No LND found — using ldk (built-in Lightning)" @@ -458,7 +484,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th gatewayd --data-dir /data --listen 0.0.0.0:8176 \ --bcrypt-password-hash "$FEDI_HASH" \ --network bitcoin --bitcoind-url http://"$TARGET_IP":8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ + --bitcoind-username "$BITCOIN_RPC_USER" --bitcoind-password "$BITCOIN_RPC_PASS" \ ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway 2>>"$LOG" || true fi fi