feat: deterministic TLS certificate from relay identity seed

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-07 22:10:08 +04:00
parent 0a05e62c7f
commit 087bfd2335
5 changed files with 68 additions and 9 deletions

View File

@@ -40,7 +40,7 @@ codec2 = "0.3"
# Crypto # Crypto
x25519-dalek = { version = "2", features = ["static_secrets"] } 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" chacha20poly1305 = "0.10"
hkdf = "0.12" hkdf = "0.12"
sha2 = "0.10" sha2 = "0.10"

View File

@@ -267,7 +267,9 @@ async fn main() -> anyhow::Result<()> {
info!(" fingerprint: \"{relay_fp}\""); 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))?; let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
// Forward mode // Forward mode

View File

@@ -16,6 +16,9 @@ async-trait = { workspace = true }
serde_json = "1" serde_json = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rcgen = "0.13" rcgen = "0.13"
ed25519-dalek = { workspace = true }
hkdf = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -6,20 +6,74 @@ use std::time::Duration;
use quinn::crypto::rustls::QuicClientConfig; use quinn::crypto::rustls::QuicClientConfig;
use quinn::crypto::rustls::QuicServerConfig; 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: /// The certificate changes on every call. Use `server_config_from_seed` for
/// - 30s idle timeout /// a deterministic certificate that survives relay restarts.
/// - 5s keep-alive interval
/// - DATAGRAM extension enabled
/// - Conservative flow control for bandwidth-constrained links
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) { pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.expect("failed to generate self-signed cert"); .expect("failed to generate self-signed cert");
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert); let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
let key_der = let key_der =
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap(); 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<u8>) {
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::<Sha256>::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::<Vec<_>>()
.join(":")
}
fn build_server_config(
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
) -> (quinn::ServerConfig, Vec<u8>) {
let mut server_crypto = rustls::ServerConfig::builder() let mut server_crypto = rustls::ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(vec![cert_der.clone()], key_der) .with_single_cert(vec![cert_der.clone()], key_der)

View File

@@ -22,7 +22,7 @@ pub mod path_monitor;
pub mod quic; pub mod quic;
pub mod reliable; 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 connection::{accept, connect, create_endpoint};
pub use path_monitor::PathMonitor; pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport; pub use quic::QuinnTransport;