diff --git a/Cargo.lock b/Cargo.lock index c0b5311..e314f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "arc-swap", "async-trait", "bytes", + "ed25519-dalek", "futures", "hex", "iroh", @@ -78,11 +79,13 @@ dependencies = [ "rand 0.8.6", "rcgen 0.13.2", "rustls", + "rustls-acme", "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.18", "tokio", "toml", @@ -212,6 +215,18 @@ dependencies = [ "syn", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -225,6 +240,54 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-http-codec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "096146020b08dbc4587685b0730a7ba905625af13c65f8028035cdfd69573c91" +dependencies = [ + "anyhow", + "futures", + "http", + "httparse", + "log", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -236,6 +299,25 @@ dependencies = [ "syn", ] +[[package]] +name = "async-web-client" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8caf502b44d6d4be6154ac33af012cbb5fef11e6066edcfb42834217fbaf501b" +dependencies = [ + "async-http-codec", + "async-net", + "futures", + "futures-rustls", + "http", + "lazy_static", + "log", + "rustls-pki-types", + "serde", + "thiserror 1.0.69", + "webpki-roots 0.26.11", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -381,6 +463,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bounded-integer" version = "0.5.8" @@ -991,6 +1086,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1139,6 +1255,17 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1305,6 +1432,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2033,6 +2166,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2690,6 +2829,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkarr" version = "3.10.0" @@ -2773,6 +2923,20 @@ dependencies = [ "pnet_macros_support", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3112,6 +3276,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ + "aws-lc-rs", "pem", "ring", "rustls-pki-types", @@ -3262,6 +3427,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.41" @@ -3278,6 +3456,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-acme" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f05935c0b1d7c5981c40b768c5d5ed96a43f5cb5166f8f5be09779c5825697" +dependencies = [ + "async-io", + "async-trait", + "async-web-client", + "aws-lc-rs", + "base64", + "blocking", + "chrono", + "futures", + "futures-rustls", + "http", + "log", + "pem", + "rcgen 0.13.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "webpki-roots 0.26.11", + "x509-parser 0.16.0", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -3844,6 +4048,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/alknet-core/Cargo.toml b/crates/alknet-core/Cargo.toml index 37d0a8a..5fec506 100644 --- a/crates/alknet-core/Cargo.toml +++ b/crates/alknet-core/Cargo.toml @@ -13,6 +13,7 @@ name = "alknet_core" default = ["quinn"] quinn = ["dep:quinn"] iroh = ["dep:iroh"] +acme = ["dep:rustls-acme"] [dependencies] tokio = { version = "1", features = ["full"] } @@ -34,4 +35,9 @@ futures = "0.3" sha2 = "0.10" hex = "0.4" rand = "0.8" -rcgen = "0.13" \ No newline at end of file +rcgen = "0.13" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] } + +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/crates/alknet-core/src/config.rs b/crates/alknet-core/src/config.rs index 1142c56..ecd0006 100644 --- a/crates/alknet-core/src/config.rs +++ b/crates/alknet-core/src/config.rs @@ -29,15 +29,72 @@ pub struct StaticConfig { pub drain_timeout: Duration, } +#[derive(Clone)] +pub struct Ed25519SecretKey(ed25519_dalek::SigningKey); + +impl Ed25519SecretKey { + pub fn generate() -> Self { + let mut csprng = rand::rngs::OsRng; + Self(ed25519_dalek::SigningKey::generate(&mut csprng)) + } + + pub fn from_bytes(bytes: &[u8; 32]) -> Self { + Self(ed25519_dalek::SigningKey::from_bytes(bytes)) + } + + pub fn as_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } + + pub fn public(&self) -> ed25519_dalek::VerifyingKey { + self.0.verifying_key() + } + + pub fn sign(&self, message: &[u8]) -> ed25519_dalek::Signature { + use ed25519_dalek::Signer; + self.0.sign(message) + } +} + +impl std::fmt::Debug for Ed25519SecretKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Ed25519SecretKey").finish_non_exhaustive() + } +} + +impl zeroize::ZeroizeOnDrop for Ed25519SecretKey {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcmeDirectory { + Production, + Staging, + Custom(String), +} + +impl AcmeDirectory { + pub fn url(&self) -> &str { + match self { + AcmeDirectory::Production => "https://acme-v02.api.letsencrypt.org/directory", + AcmeDirectory::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory", + AcmeDirectory::Custom(url) => url, + } + } +} + #[derive(Debug, Clone)] pub enum TlsIdentity { X509 { cert: PathBuf, key: PathBuf, }, - #[cfg(feature = "iroh")] - RawKey(iroh::SecretKey), + RawKey(Ed25519SecretKey), SelfSigned, + Acme { + domains: Vec, + cache_dir: PathBuf, + directory: AcmeDirectory, + contact: Vec, + }, } #[derive(Debug, Clone, Default)] diff --git a/crates/alknet-core/src/endpoint.rs b/crates/alknet-core/src/endpoint.rs index 2e25d19..3444933 100644 --- a/crates/alknet-core/src/endpoint.rs +++ b/crates/alknet-core/src/endpoint.rs @@ -103,6 +103,8 @@ pub struct AlknetEndpoint { shutdown_tx: watch::Sender, shutdown_rx: watch::Receiver, drain_timeout: Duration, + #[cfg(feature = "acme")] + acme_state_handle: Option>, } #[cfg(any(feature = "quinn", feature = "iroh"))] @@ -129,19 +131,29 @@ impl AlknetEndpoint { let drain_timeout = static_config.drain_timeout; #[cfg(feature = "quinn")] - let quinn = if let Some(listen_addr) = static_config.listen_addr { + #[cfg_attr(not(feature = "acme"), allow(unused_variables))] + let (quinn, acme_state_handle) = if let Some(listen_addr) = static_config.listen_addr { let tls_identity = static_config.tls_identity.as_ref().ok_or_else(|| { EndpointError::TlsConfig(io::Error::new( io::ErrorKind::InvalidInput, "quinn endpoint requires tls_identity in static config", )) })?; - let server_config = build_quinn_server_config(tls_identity, &alpns)?; + let tls_setup = TlsSetup::new(tls_identity, &alpns).await?; + let server_config = + build_quinn_server_config_from_rustls(tls_setup.server_config)?; let endpoint = quinn::Endpoint::server(server_config, listen_addr) .map_err(EndpointError::BindFailed)?; - Some(endpoint) + #[cfg(feature = "acme")] + { + (Some(endpoint), tls_setup.acme_state_handle) + } + #[cfg(not(feature = "acme"))] + { + (Some(endpoint), None::>) + } } else { - None + (None, None) }; #[cfg(not(feature = "quinn"))] @@ -170,6 +182,8 @@ impl AlknetEndpoint { shutdown_tx, shutdown_rx, drain_timeout, + #[cfg(feature = "acme")] + acme_state_handle, }) } @@ -190,6 +204,11 @@ impl AlknetEndpoint { iroh.close().await; } + #[cfg(feature = "acme")] + if let Some(handle) = &self.acme_state_handle { + handle.abort(); + } + tokio::time::sleep(self.drain_timeout).await; #[cfg(feature = "quinn")] @@ -290,6 +309,14 @@ fn dispatch_quinn( identity_provider: &Arc, ) { let alpn = extract_quinn_alpn(&connection); + + #[cfg(feature = "acme")] + if alpn == b"acme-tls/1" { + debug!("acme-tls/1 challenge connection completed at TLS layer; closing"); + connection.close(0u32.into(), b"acme done"); + return; + } + let handler = match handlers.get(&alpn) { Some(h) => h.clone(), None => { @@ -448,12 +475,135 @@ fn build_auth_context( } #[cfg(feature = "quinn")] -fn build_quinn_server_config( - tls_identity: &TlsIdentity, - alpns: &[Vec], +struct TlsSetup { + server_config: rustls::ServerConfig, + #[cfg(feature = "acme")] + acme_state_handle: Option>, +} +#[cfg(feature = "quinn")] +impl TlsSetup { + async fn new( + tls_identity: &TlsIdentity, + alpns: &[Vec], + ) -> Result { + match tls_identity { + TlsIdentity::Acme { + domains, + cache_dir, + directory, + contact, + } => { + #[cfg(feature = "acme")] + { + Self::new_acme(domains, cache_dir, directory, contact, alpns).await + } + #[cfg(not(feature = "acme"))] + { + let _ = (domains, cache_dir, directory, contact, alpns); + Err(EndpointError::TlsConfig(io::Error::new( + io::ErrorKind::Unsupported, + "ACME feature not enabled but TlsIdentity::Acme configured", + ))) + } + } + _ => { + let server_config = build_rustls_server_config(tls_identity, alpns)?; + Ok(Self { + server_config, + #[cfg(feature = "acme")] + acme_state_handle: None, + }) + } + } + } + + #[cfg(feature = "acme")] + async fn new_acme( + domains: &[String], + cache_dir: &std::path::Path, + directory: &crate::config::AcmeDirectory, + contact: &[String], + alpns: &[Vec], + ) -> Result { + use rustls_acme::caches::DirCache; + use rustls_acme::{AcmeConfig, EventError, EventOk}; + + let acme_config = AcmeConfig::new(domains.to_vec()) + .cache(DirCache::new(cache_dir.to_path_buf())) + .directory(directory.url()) + .contact(contact.iter().map(|c| c.as_str())); + + let state = acme_config.state(); + let resolver = state.resolver(); + + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let mut config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))? + .with_client_cert_verifier(Arc::new(AcceptAnyCertVerifier)) + .with_cert_resolver(resolver); + config.max_early_data_size = u32::MAX; + + let mut alpn = alpns.to_vec(); + alpn.push(b"acme-tls/1".to_vec()); + config.alpn_protocols = alpn; + + let domains_owned: Vec = domains.to_vec(); + let handle = tokio::spawn(async move { + use futures::StreamExt; + let mut state = state; + while let Some(event) = state.next().await { + match event { + Ok(EventOk::DeployedCachedCert) => { + debug!(domains = ?domains_owned, "ACME: deployed cached certificate"); + } + Ok(EventOk::DeployedNewCert) => { + debug!(domains = ?domains_owned, "ACME: deployed new certificate"); + } + Ok(EventOk::CertCacheStore) => { + debug!(domains = ?domains_owned, "ACME: certificate stored to cache"); + } + Ok(EventOk::AccountCacheStore) => { + debug!(domains = ?domains_owned, "ACME: account stored to cache"); + } + Err(EventError::CertCacheLoad(e)) => { + error!(domains = ?domains_owned, error = ?e, "ACME: certificate cache load failed"); + } + Err(EventError::AccountCacheLoad(e)) => { + error!(domains = ?domains_owned, error = ?e, "ACME: account cache load failed"); + } + Err(EventError::CertCacheStore(e)) => { + warn!(domains = ?domains_owned, error = ?e, "ACME: certificate cache store failed"); + } + Err(EventError::AccountCacheStore(e)) => { + warn!(domains = ?domains_owned, error = ?e, "ACME: account cache store failed"); + } + Err(EventError::CachedCertParse(e)) => { + error!(domains = ?domains_owned, error = ?e, "ACME: cached certificate parse failed"); + } + Err(EventError::Order(e)) => { + warn!(domains = ?domains_owned, error = ?e, "ACME: certificate order failed, will retry"); + } + Err(EventError::NewCertParse(e)) => { + error!(domains = ?domains_owned, error = ?e, "ACME: new certificate parse failed"); + } + } + } + debug!(domains = ?domains_owned, "ACME: state machine ended"); + }); + + Ok(Self { + server_config: config, + acme_state_handle: Some(handle), + }) + } +} + +#[cfg(feature = "quinn")] +fn build_quinn_server_config_from_rustls( + rustls_config: rustls::ServerConfig, ) -> Result { use quinn::crypto::rustls::QuicServerConfig; - let rustls_config = build_rustls_server_config(tls_identity, alpns)?; let quic_server_config = QuicServerConfig::try_from(rustls_config) .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))?; Ok(quinn::ServerConfig::with_crypto(Arc::new( @@ -467,6 +617,7 @@ fn build_rustls_server_config( alpns: &[Vec], ) -> Result { let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let client_verifier = Arc::new(AcceptAnyCertVerifier); match tls_identity { TlsIdentity::X509 { cert, key } => { let cert_chain = load_cert_chain(cert)?; @@ -474,20 +625,19 @@ fn build_rustls_server_config( let mut config = rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))? - .with_no_client_auth() + .with_client_cert_verifier(client_verifier) .with_single_cert(cert_chain, private_key) .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))?; config.alpn_protocols = alpns.to_vec(); config.max_early_data_size = u32::MAX; Ok(config) } - #[cfg(feature = "iroh")] TlsIdentity::RawKey(secret_key) => { let resolver = Arc::new(RawKeyCertResolver::new(secret_key)); let mut config = rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))? - .with_no_client_auth() + .with_client_cert_verifier(client_verifier) .with_cert_resolver(resolver); config.alpn_protocols = alpns.to_vec(); config.max_early_data_size = u32::MAX; @@ -498,13 +648,16 @@ fn build_rustls_server_config( let mut config = rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))? - .with_no_client_auth() + .with_client_cert_verifier(client_verifier) .with_single_cert(cert.cert_chain, cert.private_key) .map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))?; config.alpn_protocols = alpns.to_vec(); config.max_early_data_size = u32::MAX; Ok(config) } + TlsIdentity::Acme { .. } => { + unreachable!("TlsIdentity::Acme is handled by TlsSetup::new_acme, not build_rustls_server_config") + } } } @@ -516,7 +669,8 @@ async fn build_iroh_endpoint( let mut builder = iroh::Endpoint::builder(); if let Some(TlsIdentity::RawKey(secret_key)) = static_config.tls_identity.as_ref() { - builder = builder.secret_key(secret_key.clone()); + let iroh_key = iroh::SecretKey::from_bytes(&secret_key.as_bytes()); + builder = builder.secret_key(iroh_key); } else { let mut csprng = rand::rngs::OsRng; builder = builder.secret_key(iroh::SecretKey::generate(&mut csprng)); @@ -589,14 +743,80 @@ fn generate_self_signed_cert() -> Result { }) } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] +struct AcceptAnyCertVerifier; + +#[cfg(feature = "quinn")] +impl std::fmt::Debug for AcceptAnyCertVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcceptAnyCertVerifier").finish() + } +} + +#[cfg(feature = "quinn")] +impl rustls::server::danger::ClientCertVerifier for AcceptAnyCertVerifier { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + false + } + + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::server::danger::ClientCertVerified::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + ] + } +} + +#[cfg(feature = "quinn")] struct RawKeyCertResolver { key: Arc, } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl RawKeyCertResolver { - fn new(secret_key: &iroh::SecretKey) -> Self { + fn new(secret_key: &crate::config::Ed25519SecretKey) -> Self { let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone())); let public_key = signing_key.spki_public_key(); let cert = rustls::pki_types::CertificateDer::from(public_key.to_vec()); @@ -607,7 +827,7 @@ impl RawKeyCertResolver { } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl rustls::server::ResolvesServerCert for RawKeyCertResolver { fn resolve( &self, @@ -621,29 +841,29 @@ impl rustls::server::ResolvesServerCert for RawKeyCertResolver { } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl std::fmt::Debug for RawKeyCertResolver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RawKeyCertResolver").finish() } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] #[derive(Clone)] struct Ed25519SigningKey { - key: iroh::SecretKey, + key: crate::config::Ed25519SecretKey, } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl std::fmt::Debug for Ed25519SigningKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Ed25519SigningKey").finish() } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl Ed25519SigningKey { - fn new(key: iroh::SecretKey) -> Self { + fn new(key: crate::config::Ed25519SecretKey) -> Self { Self { key } } @@ -655,7 +875,7 @@ impl Ed25519SigningKey { } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl rustls::sign::SigningKey for Ed25519SigningKey { fn choose_scheme( &self, @@ -677,7 +897,7 @@ impl rustls::sign::SigningKey for Ed25519SigningKey { } } -#[cfg(all(feature = "quinn", feature = "iroh"))] +#[cfg(feature = "quinn")] impl rustls::sign::Signer for Ed25519SigningKey { fn sign(&self, message: &[u8]) -> Result, rustls::Error> { Ok(self.key.sign(message).to_bytes().to_vec()) @@ -850,12 +1070,11 @@ mod tests { assert!(auth.tls_client_fingerprint.is_some()); } - #[cfg(all(feature = "quinn", feature = "iroh"))] + #[cfg(feature = "quinn")] #[test] fn raw_key_cert_resolver_only_raw_public_keys() { use rustls::server::ResolvesServerCert; - let mut csprng = rand::rngs::OsRng; - let sk = iroh::SecretKey::generate(&mut csprng); + let sk = crate::config::Ed25519SecretKey::generate(); let resolver = RawKeyCertResolver::new(&sk); assert!(resolver.only_raw_public_keys()); } @@ -863,10 +1082,9 @@ mod tests { #[cfg(feature = "iroh")] #[tokio::test] async fn endpoint_constructs_with_iroh_raw_key_identity() { - let mut csprng = rand::rngs::OsRng; let static_config = StaticConfig { listen_addr: None, - tls_identity: Some(TlsIdentity::RawKey(iroh::SecretKey::generate(&mut csprng))), + tls_identity: Some(TlsIdentity::RawKey(crate::config::Ed25519SecretKey::generate())), iroh_relay: None, drain_timeout: Duration::from_millis(10), }; @@ -894,7 +1112,7 @@ mod tests { #[tokio::test] async fn iroh_endpoint_runs_accept_loop_and_shutdown() { use std::sync::Mutex; - let server_sk = iroh::SecretKey::generate(&mut rand::rngs::OsRng); + let server_sk = crate::config::Ed25519SecretKey::generate(); let static_config = StaticConfig { listen_addr: None, tls_identity: Some(TlsIdentity::RawKey(server_sk)), @@ -1042,4 +1260,60 @@ mod tests { let b = fingerprint_from_cert_der(cert).unwrap(); assert_eq!(a, b, "same cert DER must produce same fingerprint"); } + + #[test] + fn acme_directory_production_url() { + use crate::config::AcmeDirectory; + let dir = AcmeDirectory::Production; + assert_eq!( + dir.url(), + "https://acme-v02.api.letsencrypt.org/directory" + ); + } + + #[test] + fn acme_directory_staging_url() { + use crate::config::AcmeDirectory; + let dir = AcmeDirectory::Staging; + assert_eq!( + dir.url(), + "https://acme-staging-v02.api.letsencrypt.org/directory" + ); + } + + #[test] + fn acme_directory_custom_url() { + use crate::config::AcmeDirectory; + let url = "https://custom-acme.example.com/directory"; + let dir = AcmeDirectory::Custom(url.to_string()); + assert_eq!(dir.url(), url); + } + + #[cfg(feature = "quinn")] + #[tokio::test] + async fn tls_setup_x509_returns_no_acme_state() { + use rcgen::{CertificateParams, KeyPair}; + let key_pair = KeyPair::generate().unwrap(); + let params = CertificateParams::default(); + let cert = params.self_signed(&key_pair).unwrap(); + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, cert_pem).unwrap(); + std::fs::write(&key_path, key_pem).unwrap(); + + let tls_identity = TlsIdentity::X509 { + cert: cert_path, + key: key_path, + }; + let setup = TlsSetup::new(&tls_identity, &[b"alknet/test".to_vec()]) + .await + .expect("X509 tls setup should succeed"); + let _ = setup.server_config; + #[cfg(feature = "acme")] + assert!(setup.acme_state_handle.is_none()); + } } diff --git a/docs/architecture/crates/core/auth.md b/docs/architecture/crates/core/auth.md index e67922a..2869ffe 100644 --- a/docs/architecture/crates/core/auth.md +++ b/docs/architecture/crates/core/auth.md @@ -185,6 +185,22 @@ unresolved at the endpoint layer. A follow-up task will switch the server config to request-but-not-require client certs so fingerprints flow for peers that present them. +### Server-side client cert request + +The quinn `rustls::ServerConfig` uses a custom `AcceptAnyCertVerifier` +that requests client certs but does not require them and does not verify +them against a CA. This is the "request-but-don't-require" mode: peers +that present a cert (X.509 or RFC 7250 raw key) have their fingerprint +extracted via `peer_identity()`; peers that don't present a cert connect +normally with `tls_client_fingerprint: None`. + +The verifier accepts any presented cert without CA verification because +alknet's identity model is fingerprint-based, not PKI-based — the +`AuthPolicy::authorized_fingerprints` set is the trust anchor, not a +root CA store. The cert bytes are extracted at the TLS layer and hashed +to a fingerprint string; the fingerprint is then matched against the +configured set by `IdentityProvider::resolve_from_fingerprint()`. + ## Resolution Flow ### Endpoint-level (before `handle()`) diff --git a/tasks/core/acme-integration.md b/tasks/core/acme-integration.md index 0d77eb4..b49c23f 100644 --- a/tasks/core/acme-integration.md +++ b/tasks/core/acme-integration.md @@ -1,7 +1,7 @@ --- id: core/acme-integration name: Add ACME auto-provisioning via rustls-acme (ADR-027) -status: pending +status: completed depends_on: [core/rawkey-decouple-from-iroh] scope: moderate risk: medium diff --git a/tasks/core/endpoint-request-client-cert.md b/tasks/core/endpoint-request-client-cert.md index 2b43392..6473d14 100644 --- a/tasks/core/endpoint-request-client-cert.md +++ b/tasks/core/endpoint-request-client-cert.md @@ -1,7 +1,7 @@ --- id: core/endpoint-request-client-cert name: Switch rustls ServerConfig from with_no_client_auth to request-but-don't-require client certs -status: pending +status: completed depends_on: [core/endpoint-client-fingerprint] scope: narrow risk: medium diff --git a/tasks/core/rawkey-decouple-from-iroh.md b/tasks/core/rawkey-decouple-from-iroh.md index 789bf16..4b5fb8e 100644 --- a/tasks/core/rawkey-decouple-from-iroh.md +++ b/tasks/core/rawkey-decouple-from-iroh.md @@ -1,7 +1,7 @@ --- id: core/rawkey-decouple-from-iroh name: Decouple TlsIdentity::RawKey from the iroh feature (ADR-027) -status: pending +status: completed depends_on: [] scope: narrow risk: medium