fix: rpcauth credentials, reboot survival, system Tor for all containers
- Bitcoin RPC: switch to rpcauth (salted hash in bitcoin.conf, no plaintext in config or CLI). Password stable across reboots/restarts/deploys. - Remove daily-reboot-test.sh cron on both servers - Enable podman-restart.service for container auto-start after reboot - System Tor: SocksPort 0.0.0.0:9050 with SocksPolicy for container access - LND: tor.socks=host.containers.internal:9050 (system Tor, not container) - Bitcoin: -proxy=host.containers.internal:9050 for Tor outbound - bitcoin_rpc.rs: reads from secrets file, cached, stable credentials - package.rs: dynamic rpc_user/rpc_pass, rpcauth hash generation - network.rs: fix missing send_to_peer args (mesh encryption update) - first-boot-containers.sh: rpcauth generation, system Tor config - deploy-to-target.sh: rpcauth credentials, LND config migration - Mesh: encrypted channel message support (ChaCha20-Poly1305 updates) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
adf0aa465f
commit
6f5188ef7f
@@ -66,6 +66,10 @@ pub struct MeshState {
|
||||
pub stego_mode: super::steganography::SteganographyMode,
|
||||
/// Chunk reassembly buffer for multi-frame messages.
|
||||
chunk_buffer: RwLock<HashMap<(u32, u8), ChunkAssembly>>,
|
||||
/// Double Ratchet session manager for forward-secret encryption.
|
||||
pub session_manager: Arc<super::session::SessionManager>,
|
||||
/// 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<super::bitcoin_relay::BlockHeaderCache>,
|
||||
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
|
||||
stego_mode: super::steganography::SteganographyMode,
|
||||
encrypt_relay: bool,
|
||||
session_manager: Arc<super::session::SessionManager>,
|
||||
) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
|
||||
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<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
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<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
@@ -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<MeshState>,
|
||||
contact_id: u32,
|
||||
typed_wire: &[u8],
|
||||
) -> Vec<u8> {
|
||||
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<MeshState>, contact_id: u32, payload: Vec<u8>) {
|
||||
/// 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<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
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<MeshState>, contact_id: u32, payload: Vec<u8>)
|
||||
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<MeshState>, contact_id: u32, payload: Vec<u8>)
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user