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.
This commit is contained in:
2026-04-03 10:48:16 +02:00
parent fb6b80c81c
commit 4dadd01c6b
4 changed files with 399 additions and 1 deletions

View File

@@ -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"] }

View File

@@ -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<Vec<u8>, 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<Vec<u8>, 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::<sha2::Sha256>::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);
}
}

View File

@@ -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 {