From 087bfd233591b0efaee460ee0bc0662415f181f0 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 7 Apr 2026 22:10:08 +0400 Subject: [PATCH] feat: deterministic TLS certificate from relay identity seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay's TLS certificate is now derived from the persisted Ed25519 seed via HKDF, so the same seed produces the same cert and the same TLS fingerprint across restarts. This fixes the "Server Key Changed" warnings on every relay restart. Implementation: HKDF-SHA256(seed, "wzp-tls-ed25519") → Ed25519 signing key → PKCS8 DER → rcgen KeyPair → self-signed cert. Also adds tls_fingerprint() helper (SHA-256 of DER cert, hex with colons) and prints it on startup. This is the prerequisite for relay federation (peers verify each other by TLS fingerprint). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 2 +- crates/wzp-relay/src/main.rs | 4 +- crates/wzp-transport/Cargo.toml | 3 ++ crates/wzp-transport/src/config.rs | 66 +++++++++++++++++++++++++++--- crates/wzp-transport/src/lib.rs | 2 +- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1daa196..dfe4b50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ codec2 = "0.3" # Crypto x25519-dalek = { version = "2", features = ["static_secrets"] } -ed25519-dalek = { version = "2", features = ["rand_core"] } +ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] } chacha20poly1305 = "0.10" hkdf = "0.12" sha2 = "0.10" diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index abc380b..3388754 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -267,7 +267,9 @@ async fn main() -> anyhow::Result<()> { info!(" fingerprint: \"{relay_fp}\""); } - let (server_config, _cert) = wzp_transport::server_config(); + let (server_config, cert_der) = wzp_transport::server_config_from_seed(&relay_seed.0); + let tls_fp = wzp_transport::tls_fingerprint(&cert_der); + info!(tls_fingerprint = %tls_fp, "TLS certificate (deterministic from relay identity)"); let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?; // Forward mode diff --git a/crates/wzp-transport/Cargo.toml b/crates/wzp-transport/Cargo.toml index 5d32bda..671dfd9 100644 --- a/crates/wzp-transport/Cargo.toml +++ b/crates/wzp-transport/Cargo.toml @@ -16,6 +16,9 @@ async-trait = { workspace = true } serde_json = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rcgen = "0.13" +ed25519-dalek = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/wzp-transport/src/config.rs b/crates/wzp-transport/src/config.rs index 6138fd9..2854bf9 100644 --- a/crates/wzp-transport/src/config.rs +++ b/crates/wzp-transport/src/config.rs @@ -6,20 +6,74 @@ use std::time::Duration; use quinn::crypto::rustls::QuicClientConfig; use quinn::crypto::rustls::QuicServerConfig; -/// Create a server configuration with a self-signed certificate (for testing). +/// Create a server configuration with a self-signed certificate (random keypair). /// -/// Tunes QUIC transport parameters for lossy VoIP: -/// - 30s idle timeout -/// - 5s keep-alive interval -/// - DATAGRAM extension enabled -/// - Conservative flow control for bandwidth-constrained links +/// The certificate changes on every call. Use `server_config_from_seed` for +/// a deterministic certificate that survives relay restarts. pub fn server_config() -> (quinn::ServerConfig, Vec) { let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) .expect("failed to generate self-signed cert"); let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert); let key_der = rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap(); + build_server_config(cert_der, key_der) +} +/// Create a server configuration with a deterministic self-signed certificate +/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint. +pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec) { + use ed25519_dalek::pkcs8::EncodePrivateKey; + use ed25519_dalek::SigningKey; + use hkdf::Hkdf; + use sha2::Sha256; + + // Derive Ed25519 key bytes from seed via HKDF + let hk = Hkdf::::new(None, seed); + let mut ed_bytes = [0u8; 32]; + hk.expand(b"wzp-tls-ed25519", &mut ed_bytes) + .expect("HKDF expand failed"); + + // Create Ed25519 signing key and export as PKCS8 DER + let signing_key = SigningKey::from_bytes(&ed_bytes); + let pkcs8_doc = signing_key.to_pkcs8_der() + .expect("failed to encode Ed25519 key as PKCS8"); + let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec()) + .expect("failed to wrap PKCS8 DER"); + + // Create rcgen KeyPair from DER + let key_pair = rcgen::KeyPair::from_der_and_sign_algo( + &key_der_for_rcgen, + &rcgen::PKCS_ED25519, + ) + .expect("failed to create KeyPair from seed-derived Ed25519 key"); + + // Build self-signed cert with this deterministic keypair + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("failed to create CertificateParams"); + let cert = params.self_signed(&key_pair).expect("failed to self-sign cert"); + let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); + let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()) + .expect("failed to serialize key DER"); + + build_server_config(cert_der, key_der) +} + +/// Compute a hex-formatted SHA-256 fingerprint of a DER-encoded certificate. +/// +/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons). +pub fn tls_fingerprint(cert_der: &[u8]) -> String { + use sha2::{Sha256, Digest}; + let hash = Sha256::digest(cert_der); + hash.iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +fn build_server_config( + cert_der: rustls::pki_types::CertificateDer<'static>, + key_der: rustls::pki_types::PrivateKeyDer<'static>, +) -> (quinn::ServerConfig, Vec) { let mut server_crypto = rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert(vec![cert_der.clone()], key_der) diff --git a/crates/wzp-transport/src/lib.rs b/crates/wzp-transport/src/lib.rs index 978155d..4034701 100644 --- a/crates/wzp-transport/src/lib.rs +++ b/crates/wzp-transport/src/lib.rs @@ -22,7 +22,7 @@ pub mod path_monitor; pub mod quic; pub mod reliable; -pub use config::{client_config, server_config}; +pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint}; pub use connection::{accept, connect, create_endpoint}; pub use path_monitor::PathMonitor; pub use quic::QuinnTransport;