Implement the `tcp-tls` protocol crate and generic tunnel codec abstractions alongside `wg-quic` to circumvent deep packet inspection (DPI/TSPU), UDP blocking, and QUIC throttling. Traffic uses standard TCP on port 443 with TLS 1.3 framing, looking identical to regular HTTPS traffic to network observers. Architectural highlights: - TunnelCodec & GenericTunnelPlugin: Provide an extensible plugin abstraction in `crates/tsunagi/src/dataplane/tunnel.rs` that encapsulates background task lifecycles, per-peer channels, and sink dispatch, so new transport plugins only define cryptographic transformations and handshake payloads. - MultiPacketTransport & Protocol Priority Selection: Support multiple simultaneous transports in `AgentConfig` and dynamically dispatch `open()` calls by protocol identifier. In `ensure_links`, choose the highest-priority mutually supported protocol per peer to avoid duplicate links. - TLS 1.3 & Certificate Pinning: Generate self-signed Ed25519 X.509 certificates derived from the persistent `DeviceIdentity` secret. Clients pin peer certificates using `PinnedEndpointVerifier` against `iroh::EndpointId`. - End-to-end Encryption: `PeerCipher` uses X25519 static-static ECDH, HKDF-SHA256, and ChaCha20-Poly1305 with incrementing 64-bit nonces. Relayed traffic across intermediate nodes remains confidential and tamper-proof. - Privilege handling: Default to TCP port 443 with fallback to 8443 on `PermissionDenied`, providing an actionable message recommending: `sudo setcap cap_net_admin,cap_net_bind_service+p <path>`. Validation on Linux: - `cargo fmt --all -- --check` passed. - `cargo clippy --locked --workspace --all-targets -- -D warnings` passed. - `cargo test --locked --workspace --all-targets` passed (all workspace unit, integration, CLI, and new `tsunagi-tcp-tls` tests passed).
105 lines
3.6 KiB
Rust
105 lines
3.6 KiB
Rust
//! End-to-end packet encryption for `tcp-tls`.
|
|
//!
|
|
//! Uses X25519 Diffie-Hellman key exchange and ChaCha20-Poly1305 AEAD.
|
|
//! This ensures that even when traffic passes through intermediate relay nodes,
|
|
//! the intermediate nodes cannot inspect or tamper with the payload.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
|
use hkdf::Hkdf;
|
|
use iroh::EndpointId;
|
|
use sha2::Sha256;
|
|
use tsunagi::dataplane::PluginError;
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|
|
|
/// Length of the X25519 public key.
|
|
pub const KEY_LEN: usize = 32;
|
|
|
|
/// Domain separator for session key derivation.
|
|
const HKDF_SALT: &[u8] = b"tsunagi/e2ee/noise/v1";
|
|
|
|
/// A session cipher for communicating with one peer.
|
|
pub struct PeerCipher {
|
|
tx_cipher: ChaCha20Poly1305,
|
|
rx_cipher: ChaCha20Poly1305,
|
|
tx_nonce: AtomicU64,
|
|
}
|
|
|
|
impl PeerCipher {
|
|
/// Establishes a new cipher between `local_id` and `remote_id` given
|
|
/// our local secret and their public key.
|
|
pub fn new(
|
|
local_id: EndpointId,
|
|
remote_id: EndpointId,
|
|
local_secret: &StaticSecret,
|
|
remote_public: &PublicKey,
|
|
) -> Self {
|
|
let shared = local_secret.diffie_hellman(remote_public);
|
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared.as_bytes());
|
|
|
|
let mut key_a_to_b = [0u8; 32];
|
|
let mut key_b_to_a = [0u8; 32];
|
|
let _ = hk.expand(b"tsunagi-key-a-to-b", &mut key_a_to_b);
|
|
let _ = hk.expand(b"tsunagi-key-b-to-a", &mut key_b_to_a);
|
|
|
|
// Deterministic role: smaller EndpointId is party A.
|
|
let is_party_a = local_id.as_bytes() < remote_id.as_bytes();
|
|
let (tx_key, rx_key) = if is_party_a {
|
|
(key_a_to_b, key_b_to_a)
|
|
} else {
|
|
(key_b_to_a, key_a_to_b)
|
|
};
|
|
|
|
let tx_cipher = ChaCha20Poly1305::new(&tx_key.into());
|
|
let rx_cipher = ChaCha20Poly1305::new(&rx_key.into());
|
|
|
|
Self {
|
|
tx_cipher,
|
|
rx_cipher,
|
|
tx_nonce: AtomicU64::new(0),
|
|
}
|
|
}
|
|
|
|
/// Encrypts an outbound packet. Prefixes the ciphertext with the 8-byte nonce.
|
|
pub fn encrypt(&self, packet: &[u8]) -> Result<bytes::Bytes, PluginError> {
|
|
let counter = self.tx_nonce.fetch_add(1, Ordering::Relaxed);
|
|
let mut nonce_bytes = [0u8; 12];
|
|
nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes());
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let ciphertext = self
|
|
.tx_cipher
|
|
.encrypt(nonce, packet)
|
|
.map_err(|err| PluginError::Other(format!("encryption failed: {err}")))?;
|
|
|
|
let mut out = Vec::with_capacity(8 + ciphertext.len());
|
|
out.extend_from_slice(&counter.to_be_bytes());
|
|
out.extend_from_slice(&ciphertext);
|
|
Ok(bytes::Bytes::from(out))
|
|
}
|
|
|
|
/// Decrypts an inbound packet prefixed with the 8-byte nonce.
|
|
pub fn decrypt(&self, payload: &[u8]) -> Result<bytes::Bytes, PluginError> {
|
|
if payload.len() < 8 + 16 {
|
|
return Err(PluginError::Other("payload too short".into()));
|
|
}
|
|
|
|
let counter_bytes: [u8; 8] = match payload[..8].try_into() {
|
|
Ok(b) => b,
|
|
Err(_) => return Err(PluginError::Other("invalid nonce".into())),
|
|
};
|
|
let mut nonce_bytes = [0u8; 12];
|
|
nonce_bytes[4..12].copy_from_slice(&counter_bytes);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let plaintext = self
|
|
.rx_cipher
|
|
.decrypt(nonce, &payload[8..])
|
|
.map_err(|err| PluginError::Other(format!("decryption failed: {err}")))?;
|
|
|
|
Ok(bytes::Bytes::from(plaintext))
|
|
}
|
|
}
|