From 4dadd01c6bb622644c56ed6be873c7b2bc66a97a Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Fri, 3 Apr 2026 10:48:16 +0200 Subject: [PATCH] feat: add E2E encryption module to meshservice X25519 key agreement + HKDF-SHA256 + ChaCha20-Poly1305 AEAD for opt-in payload encryption. Each message uses a fresh ephemeral key for forward secrecy. 11 new tests cover roundtrip, wrong-key rejection, tampering, wire format integration, and edge cases. --- Cargo.lock | 2 + crates/meshservice/Cargo.toml | 4 +- crates/meshservice/src/crypto.rs | 392 +++++++++++++++++++++++++++++++ crates/meshservice/src/lib.rs | 2 + 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 crates/meshservice/src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index d4c9e66..974f165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3207,8 +3207,10 @@ name = "meshservice" version = "0.1.0" dependencies = [ "anyhow", + "chacha20poly1305", "ciborium", "ed25519-dalek 2.2.0", + "hkdf", "rand 0.8.5", "serde", "sha2 0.10.9", diff --git a/crates/meshservice/Cargo.toml b/crates/meshservice/Cargo.toml index 8e4f59e..c174e70 100644 --- a/crates/meshservice/Cargo.toml +++ b/crates/meshservice/Cargo.toml @@ -18,7 +18,9 @@ ciborium = "0.2" ed25519-dalek = { version = "2.1", features = ["serde"] } sha2 = "0.10" rand = "0.8" -x25519-dalek = "2.0" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +chacha20poly1305 = "0.10" +hkdf = "0.12" # Async tokio = { version = "1.36", features = ["sync", "time"] } diff --git a/crates/meshservice/src/crypto.rs b/crates/meshservice/src/crypto.rs new file mode 100644 index 0000000..5d3cf20 --- /dev/null +++ b/crates/meshservice/src/crypto.rs @@ -0,0 +1,392 @@ +//! End-to-end encryption for service message payloads. +//! +//! Uses X25519 key agreement + HKDF-SHA256 key derivation + ChaCha20-Poly1305 AEAD. +//! Encryption is opt-in per message: the sender encrypts the payload before +//! constructing the `ServiceMessage`, and the recipient decrypts after receiving. +//! +//! ## Key model +//! +//! Each `ServiceIdentity` (Ed25519) can derive an X25519 keypair for encryption. +//! - Sender generates an ephemeral X25519 key per message (forward secrecy). +//! - Shared secret is computed via X25519 DH with the recipient's public key. +//! - HKDF derives a per-message encryption key. +//! - ChaCha20-Poly1305 encrypts the payload with a random nonce. +//! +//! ## Wire format of encrypted payload +//! +//! ```text +//! [1 byte: version = 0x01] +//! [32 bytes: sender ephemeral X25519 public key] +//! [12 bytes: nonce] +//! [N bytes: ciphertext + 16-byte Poly1305 tag] +//! ``` + +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use rand::RngCore; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; + +use crate::error::ServiceError; +use crate::identity::ServiceIdentity; + +/// Current encrypted payload version byte. +const ENCRYPTED_VERSION: u8 = 0x01; +/// Overhead: 1 (version) + 32 (ephemeral pubkey) + 12 (nonce) + 16 (tag). +const ENCRYPTION_OVERHEAD: usize = 1 + 32 + 12 + 16; + +/// X25519 keypair derived from a `ServiceIdentity` for encryption. +/// +/// The Ed25519 seed is reused as the X25519 static secret. This is the +/// standard Ed25519-to-X25519 conversion used by libsodium and others. +pub struct EncryptionKeyPair { + secret: StaticSecret, + public: X25519Public, +} + +impl EncryptionKeyPair { + /// Derive an encryption keypair from a `ServiceIdentity`. + pub fn from_identity(identity: &ServiceIdentity) -> Self { + let secret = StaticSecret::from(identity.secret_key()); + let public = X25519Public::from(&secret); + Self { secret, public } + } + + /// Get the X25519 public key bytes (advertise to peers for encryption). + pub fn public_bytes(&self) -> [u8; 32] { + self.public.to_bytes() + } + + /// Encrypt a plaintext payload for a specific recipient. + /// + /// Uses a fresh ephemeral key for forward secrecy: even if the sender's + /// long-term key is compromised, past messages remain confidential. + pub fn encrypt_for( + &self, + recipient_x25519_public: &[u8; 32], + plaintext: &[u8], + ) -> Result, ServiceError> { + // Generate ephemeral keypair for this message + let eph_secret = StaticSecret::random_from_rng(OsRng); + let eph_public = X25519Public::from(&eph_secret); + + // X25519 DH with recipient + let recipient_pub = X25519Public::from(*recipient_x25519_public); + let shared = eph_secret.diffie_hellman(&recipient_pub); + + // Derive encryption key via HKDF + let key = derive_key(shared.as_bytes(), b"meshservice-e2e-v1"); + + // Encrypt with ChaCha20-Poly1305 + let cipher = ChaCha20Poly1305::new((&key).into()); + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|_| ServiceError::Crypto("encryption failed".into()))?; + + // Assemble: version || ephemeral_public || nonce || ciphertext+tag + let mut out = Vec::with_capacity(ENCRYPTION_OVERHEAD + plaintext.len()); + out.push(ENCRYPTED_VERSION); + out.extend_from_slice(&eph_public.to_bytes()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + /// Decrypt an encrypted payload sent to us. + /// + /// Extracts the sender's ephemeral public key from the payload, computes + /// the shared secret with our static X25519 key, and decrypts. + pub fn decrypt(&self, encrypted: &[u8]) -> Result, ServiceError> { + if encrypted.len() < ENCRYPTION_OVERHEAD { + return Err(ServiceError::Crypto("ciphertext too short".into())); + } + + let version = encrypted[0]; + if version != ENCRYPTED_VERSION { + return Err(ServiceError::Crypto(format!( + "unsupported encryption version: {version}" + ))); + } + + let eph_public_bytes: [u8; 32] = encrypted[1..33] + .try_into() + .map_err(|_| ServiceError::Crypto("invalid ephemeral key".into()))?; + let nonce_bytes: [u8; 12] = encrypted[33..45] + .try_into() + .map_err(|_| ServiceError::Crypto("invalid nonce".into()))?; + let ciphertext = &encrypted[45..]; + + // X25519 DH with sender's ephemeral key + let eph_public = X25519Public::from(eph_public_bytes); + let shared = self.secret.diffie_hellman(&eph_public); + + // Derive decryption key + let key = derive_key(shared.as_bytes(), b"meshservice-e2e-v1"); + + // Decrypt + let cipher = ChaCha20Poly1305::new((&key).into()); + let nonce = Nonce::from_slice(&nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| ServiceError::Crypto("decryption failed".into())) + } +} + +/// Derive a 32-byte key from a shared secret using HKDF-SHA256. +fn derive_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared_secret); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .expect("HKDF expand to 32 bytes should never fail"); + key +} + +/// Check whether a payload appears to be encrypted (starts with version byte +/// and has minimum length). +pub fn is_encrypted_payload(payload: &[u8]) -> bool { + payload.len() >= ENCRYPTION_OVERHEAD && payload[0] == ENCRYPTED_VERSION +} + +/// Return the encryption overhead in bytes (useful for size budgets on +/// constrained transports like LoRa). +pub const fn encryption_overhead() -> usize { + ENCRYPTION_OVERHEAD +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::ServiceIdentity; + + #[test] + fn encrypt_decrypt_roundtrip() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let plaintext = b"Hello, encrypted mesh world!"; + let encrypted = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), plaintext) + .expect("encrypt"); + + let decrypted = recipient_keys.decrypt(&encrypted).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn wrong_recipient_cannot_decrypt() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + let wrong_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + let wrong_keys = EncryptionKeyPair::from_identity(&wrong_id); + + let encrypted = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), b"secret data") + .expect("encrypt"); + + let result = wrong_keys.decrypt(&encrypted); + assert!(result.is_err()); + } + + #[test] + fn tampered_ciphertext_fails() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let mut encrypted = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), b"do not tamper") + .expect("encrypt"); + + // Flip a byte in the ciphertext portion + let last = encrypted.len() - 1; + encrypted[last] ^= 0xff; + + let result = recipient_keys.decrypt(&encrypted); + assert!(result.is_err()); + } + + #[test] + fn truncated_ciphertext_rejected() { + let recipient_id = ServiceIdentity::generate(); + let keys = EncryptionKeyPair::from_identity(&recipient_id); + + let result = keys.decrypt(&[0x01; 10]); + assert!(result.is_err()); + } + + #[test] + fn bad_version_rejected() { + let recipient_id = ServiceIdentity::generate(); + let keys = EncryptionKeyPair::from_identity(&recipient_id); + + // Valid length but wrong version + let mut fake = vec![0x99u8; ENCRYPTION_OVERHEAD + 10]; + fake[0] = 0x99; + + let result = keys.decrypt(&fake); + assert!(result.is_err()); + } + + #[test] + fn each_encryption_produces_different_ciphertext() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let plaintext = b"same message twice"; + let enc1 = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), plaintext) + .expect("encrypt 1"); + let enc2 = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), plaintext) + .expect("encrypt 2"); + + // Different ephemeral keys + nonces => different ciphertext + assert_ne!(enc1, enc2); + + // Both decrypt to the same plaintext + let dec1 = recipient_keys.decrypt(&enc1).expect("decrypt 1"); + let dec2 = recipient_keys.decrypt(&enc2).expect("decrypt 2"); + assert_eq!(dec1, plaintext); + assert_eq!(dec2, plaintext); + } + + #[test] + fn empty_plaintext_roundtrip() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let encrypted = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), b"") + .expect("encrypt empty"); + + assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD); + + let decrypted = recipient_keys.decrypt(&encrypted).expect("decrypt empty"); + assert!(decrypted.is_empty()); + } + + #[test] + fn is_encrypted_payload_detection() { + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let encrypted = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), b"test") + .expect("encrypt"); + + assert!(is_encrypted_payload(&encrypted)); + assert!(!is_encrypted_payload(b"plain text")); + assert!(!is_encrypted_payload(&[])); + } + + #[test] + fn public_bytes_deterministic() { + let id = ServiceIdentity::generate(); + let keys1 = EncryptionKeyPair::from_identity(&id); + let keys2 = EncryptionKeyPair::from_identity(&id); + assert_eq!(keys1.public_bytes(), keys2.public_bytes()); + } + + #[test] + fn encrypt_decrypt_with_service_message() { + // Full integration: encrypt payload, wrap in ServiceMessage, decrypt + use crate::message::ServiceMessage; + use crate::service_ids::FAPP; + + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + // Encrypt the payload before creating the message + let plaintext = b"confidential appointment details"; + let encrypted_payload = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), plaintext) + .expect("encrypt"); + + // Create a signed service message with the encrypted payload + let msg = ServiceMessage::new( + &sender_id, + FAPP, + crate::message::MessageType::Reserve, + encrypted_payload.clone(), + 1, + ); + + // Verify the message signature still works (signs over encrypted payload) + assert!(msg.verify(&sender_id.public_key())); + + // Recipient decrypts the payload + let decrypted = recipient_keys.decrypt(&msg.payload).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn encrypt_decrypt_wire_roundtrip() { + // Full wire roundtrip: encrypt -> sign -> encode -> decode -> verify -> decrypt + use crate::message::ServiceMessage; + use crate::service_ids::FAPP; + use crate::wire; + + let sender_id = ServiceIdentity::generate(); + let recipient_id = ServiceIdentity::generate(); + + let sender_keys = EncryptionKeyPair::from_identity(&sender_id); + let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id); + + let plaintext = b"sensitive medical data over the mesh"; + let encrypted_payload = sender_keys + .encrypt_for(&recipient_keys.public_bytes(), plaintext) + .expect("encrypt"); + + let msg = ServiceMessage::new( + &sender_id, + FAPP, + crate::message::MessageType::Reserve, + encrypted_payload, + 42, + ); + + // Encode to wire format + let wire_bytes = wire::encode(&msg).expect("encode"); + + // Decode from wire format + let decoded = wire::decode(&wire_bytes).expect("decode"); + + // Verify signature + assert!(decoded.verify(&sender_id.public_key())); + + // Decrypt payload + let decrypted = recipient_keys.decrypt(&decoded.payload).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn encryption_overhead_constant() { + assert_eq!(encryption_overhead(), 61); + } +} diff --git a/crates/meshservice/src/lib.rs b/crates/meshservice/src/lib.rs index 421552e..9a67905 100644 --- a/crates/meshservice/src/lib.rs +++ b/crates/meshservice/src/lib.rs @@ -46,6 +46,7 @@ pub mod services; pub mod wire; pub mod error; pub mod anti_abuse; +pub mod crypto; pub use identity::ServiceIdentity; pub use message::{ServiceMessage, MessageType}; @@ -54,6 +55,7 @@ pub use store::ServiceStore; pub use verification::{Verification, VerificationLevel}; pub use error::ServiceError; pub use anti_abuse::{RateLimiter, RateLimits, ProofOfWork, SenderReputation, TherapistPolicy}; +pub use crypto::{EncryptionKeyPair, is_encrypted_payload, encryption_overhead}; /// Well-known service IDs. pub mod service_ids {