diff --git a/Cargo.lock b/Cargo.lock index d3fae75..925c3d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,6 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c1e5b6fe668491eca022f745a0a9402585626c73a7b839b3424ace15d6a9c8f" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", diff --git a/Cargo.toml b/Cargo.toml index 20569e5..8cb4390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ name = "alktls" default = [] noq = ["dep:noq", "dep:noq-proto"] tcp = ["dep:tokio-rustls"] -acme = ["dep:rustls-acme"] +acme = ["dep:rustls-acme", "dep:futures"] [dependencies] tokio = { version = "1", default-features = false, features = ["rt", "sync", "macros"] } @@ -35,7 +35,7 @@ tracing = "0.1" thiserror = "2" futures = { version = "0.3", optional = true } -noq = { version = "1.2", optional = true, default-features = false, features = ["rustls"] } +noq = { version = "1.2", optional = true, default-features = false, features = ["rustls", "aws-lc-rs"] } noq-proto = { version = "1.2", optional = true, default-features = false } tokio-rustls = { version = "0.26", optional = true } rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] } diff --git a/src/client.rs b/src/client.rs index 122bb80..8f68c7e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,3 +2,799 @@ //! [`FingerprintPinVerifier`], [`RawKeyClientCertResolver`], //! [`NoClientCertResolver`], [`select_server_verifier`], //! [`build_client_auth`], [`load_platform_root_cert_store`]. + +use std::sync::Arc; + +use crate::credentials::{ConnectionCredentials, RemoteIdentity}; +use crate::fingerprint::{extract_ed25519_raw_key_from_spki, fingerprint_from_cert_der}; +use crate::identity::TlsIdentity; + +use crate::TlsError; + +/// Client-side TLS configuration, transport-agnostic. +/// Wraps a `rustls::ClientConfig` built from `ConnectionCredentials`. +/// +/// Built per dial from a [`ConnectionCredentials`] + ALPN and consumed by +/// its accessors — a `TlsClientConfig` is not reused across dials (ADR-004). +#[allow(dead_code)] +pub struct TlsClientConfig { + pub(crate) rustls_config: rustls::ClientConfig, +} + +impl TlsClientConfig { + /// Build a client config from `ConnectionCredentials` and an ALPN. + /// Selects the server cert verifier by `remote_identity` presence + /// (alknet ADR-034 §3): `Some` → fingerprint pin, `None` → CA + /// verification. + pub fn new(credentials: &ConnectionCredentials, alpn: &[u8]) -> Result { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + + let client_auth = build_client_auth(&provider, &credentials.local_identity)?; + let verifier = select_server_verifier(&provider, &credentials.remote_identity)?; + + let mut config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(TlsError::from)? + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_client_cert_resolver(client_auth); + config.alpn_protocols = vec![alpn.to_vec()]; + config.enable_early_data = true; + + Ok(Self { + rustls_config: config, + }) + } + + /// Convert to a `noq::ClientConfig` for QUIC transport. Consumes — + /// one dial = one config build (ADR-004). + #[cfg(feature = "noq")] + pub fn for_noq(self) -> Result { + let quic_config = noq::crypto::rustls::QuicClientConfig::try_from(self.rustls_config)?; + Ok(noq::ClientConfig::new(Arc::new(quic_config))) + } + + /// Consume the config and return the inner `rustls::ClientConfig`. + /// Used by the TCP+TLS dial to build a `TlsConnector`. Consumes — + /// zero-cost, no clone behind the scenes (ADR-004). + pub fn into_rustls_config(self) -> rustls::ClientConfig { + self.rustls_config + } +} + +/// Build the client-auth cert resolver that presents the local node's TLS +/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC +/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the +/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the +/// cert chain + key are loaded from disk. `None` (no `local_identity` configured) +/// resolves to no client cert (the server gets nothing to fingerprint). +pub fn build_client_auth( + provider: &Arc, + tls_identity: &Option, +) -> Result, TlsError> { + match tls_identity { + Some(TlsIdentity::RawKey(secret_key)) => { + let signing_key = Arc::new(crate::signing::Ed25519SigningKey::new(secret_key.clone())); + let spki = signing_key.spki_public_key(); + let cert = rustls::pki_types::CertificateDer::from(spki.to_vec()); + let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key)); + Ok(Arc::new(RawKeyClientCertResolver::new(certified_key))) + } + Some(TlsIdentity::X509 { cert, key }) => { + let cert_chain = crate::pem::load_cert_chain(cert)?; + let key_der = crate::pem::load_private_key(key)?; + let certified_key = + rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider)?; + Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new( + certified_key, + )))) + } + Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)), + Some(TlsIdentity::Acme { .. }) => Err(TlsError::AcmeConfig( + "ACME TLS identity is server-only; cannot be used for client auth".to_string(), + )), + } +} + +/// Select the server cert verifier by `remote_identity` presence (alknet +/// ADR-034 §3). +/// +/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint +/// match). The fingerprint IS the trust anchor. +/// - `None` → no prior knowledge of the remote → `WebPkiServerVerifier` (CA +/// verification) for X.509 remotes. For Ed25519 raw-key remotes the +/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes +/// have no CA to fall back to — alknet ADR-034 §2 assumption 1). `None` is +/// the public-X.509-endpoint state, not "skip verification." +pub fn select_server_verifier( + provider: &Arc, + remote_identity: &Option, +) -> Result, TlsError> { + match remote_identity { + Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new( + ri.fingerprint.clone(), + provider.signature_verification_algorithms, + ))), + None => { + let roots = load_platform_root_cert_store()?; + let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::clone(provider), + ) + .build()?; + Ok(verifier) + } + } +} + +/// Load the platform's trusted root certificates into a `RootCertStore` for +/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back +/// to the built-in `webpki-roots` if the platform store is empty (e.g. in a +/// container with no system CA bundle) — alknet ADR-088 §5. +pub fn load_platform_root_cert_store() -> Result { + let mut roots = rustls::RootCertStore::empty(); + let result = rustls_native_certs::load_native_certs(); + for err in &result.errors { + tracing::warn!(error = ?err, "failed to load a native root cert"); + } + for cert in &result.certs { + roots.add(cert.clone())?; + } + if roots.is_empty() { + tracing::info!("platform root cert store is empty, falling back to webpki-roots"); + for anchor in webpki_roots::TLS_SERVER_ROOTS.iter() { + roots.roots.push(anchor.to_owned()); + } + } + Ok(roots) +} + +/// Client cert resolver that presents a single RFC 7250 raw public key (or +/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so +/// rustls negotiates the RFC 7250 ClientCertificateType extension. +pub struct RawKeyClientCertResolver { + key: Arc, + raw_public_keys: bool, +} + +impl RawKeyClientCertResolver { + pub fn new(key: Arc) -> Self { + let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]); + Self { + key, + raw_public_keys, + } + } +} + +fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool { + extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some() +} + +impl std::fmt::Debug for RawKeyClientCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawKeyClientCertResolver") + .field("raw_public_keys", &self.raw_public_keys) + .finish() + } +} + +impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver { + fn resolve( + &self, + _root_hint_subjects: &[&[u8]], + _sigschemes: &[rustls::SignatureScheme], + ) -> Option> { + Some(Arc::clone(&self.key)) + } + + fn only_raw_public_keys(&self) -> bool { + self.raw_public_keys + } + + fn has_certs(&self) -> bool { + true + } +} + +/// Client cert resolver that presents no client cert (the `local_identity: None` +/// or `SelfSigned` path). The server gets nothing to fingerprint — the +/// fingerprint → peer-id resolution path is not activated for this connection. +pub struct NoClientCertResolver; + +impl std::fmt::Debug for NoClientCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoClientCertResolver").finish() + } +} + +impl rustls::client::ResolvesClientCert for NoClientCertResolver { + fn resolve( + &self, + _root_hint_subjects: &[&[u8]], + _sigschemes: &[rustls::SignatureScheme], + ) -> Option> { + None + } + + fn has_certs(&self) -> bool { + false + } +} + +/// `ServerCertVerifier` that pins a specific fingerprint (alknet ADR-034 §3, the +/// known-peer path). For `ed25519:` remotes the raw Ed25519 pub key is +/// extracted from the presented cert and matched against the pinned +/// fingerprint; for `SHA256:` remotes the cert DER is hashed and matched +/// against the pinned fingerprint. No match → verification failure (the +/// connection is rejected). The fingerprint IS the trust anchor — there is no +/// CA verification and no name verification, only the fingerprint pin. +/// +/// Handshake signatures are still verified (using the aws-lc-rs default +/// signature verification algorithms) so that a stolen-but-stale fingerprint +/// can't be replayed with a forged signature: the presenter must prove +/// possession of the private key corresponding to the pinned public key. +pub struct FingerprintPinVerifier { + fingerprint: String, + supported: rustls::crypto::WebPkiSupportedAlgorithms, +} + +impl FingerprintPinVerifier { + pub fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self { + Self { + fingerprint, + supported, + } + } +} + +impl std::fmt::Debug for FingerprintPinVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FingerprintPinVerifier") + .field("fingerprint", &self.fingerprint) + .finish() + } +} + +impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + let presented = + fingerprint_from_cert_der(end_entity.as_ref()).ok_or(rustls::Error::General( + "fingerprint pin: failed to compute fingerprint from presented cert".to_string(), + ))?; + if presented == self.fingerprint { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } else { + Err(rustls::Error::General(format!( + "fingerprint pin mismatch: expected {} got {}", + self.fingerprint, presented + ))) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported) + } + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported) + } + } + + fn supported_verify_schemes(&self) -> Vec { + self.supported.supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::Ed25519SecretKey; + + fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec { + let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key); + spki.to_vec() + } + + fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> { + let key_pair = rcgen::KeyPair::generate().expect("key gen"); + let params = rcgen::CertificateParams::default(); + let cert = params.self_signed(&key_pair).expect("self-signed cert"); + cert.der().clone() + } + + fn aws_lc_rs_provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + fn verify_pin( + verifier: &FingerprintPinVerifier, + cert_der: rustls::pki_types::CertificateDer<'_>, + ) -> Result { + use rustls::client::danger::ServerCertVerifier; + let server_name: rustls::pki_types::ServerName<'static> = + "alktls".try_into().expect("server name"); + verifier.verify_server_cert( + &cert_der, + &[], + &server_name, + &[], + rustls::pki_types::UnixTime::now(), + ) + } + + fn dss_with_ed25519_scheme(signature: Vec) -> rustls::DigitallySignedStruct { + use rustls::internal::msgs::codec::{Codec, Reader}; + let mut encoded = Vec::new(); + rustls::SignatureScheme::ED25519.encode(&mut encoded); + (signature.len() as u16).encode(&mut encoded); + encoded.extend_from_slice(&signature); + rustls::DigitallySignedStruct::read(&mut Reader::init(&encoded)) + .expect("DigitallySignedStruct decodes from its wire encoding") + } + + #[test] + fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() { + let sk = Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let fingerprint = fingerprint_from_cert_der(&spki_der).expect("fingerprint"); + let verifier = FingerprintPinVerifier::new( + fingerprint, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let cert = rustls::pki_types::CertificateDer::from(spki_der); + let result = verify_pin(&verifier, cert); + assert!( + result.is_ok(), + "FingerprintPinVerifier must accept a cert whose fingerprint matches the pin" + ); + } + + #[test] + fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() { + let sk = Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let other_sk = Ed25519SecretKey::generate(); + let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes())); + let verifier = FingerprintPinVerifier::new( + other_fp, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let cert = rustls::pki_types::CertificateDer::from(spki_der); + let result = verify_pin(&verifier, cert); + assert!( + result.is_err(), + "FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin" + ); + } + + #[test] + fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() { + let cert_der = build_x509_cert_der(); + let fingerprint = fingerprint_from_cert_der(cert_der.as_ref()).expect("fingerprint"); + let verifier = FingerprintPinVerifier::new( + fingerprint, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let result = verify_pin(&verifier, cert_der); + assert!( + result.is_ok(), + "FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches" + ); + } + + #[test] + fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() { + let cert_der = build_x509_cert_der(); + let verifier = FingerprintPinVerifier::new( + "SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(), + aws_lc_rs_provider().signature_verification_algorithms, + ); + let result = verify_pin(&verifier, cert_der); + assert!( + result.is_err(), + "FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match" + ); + } + + #[test] + fn fingerprint_pin_verifier_routes_ed25519_spki_tls13_signature_through_raw_key_path() { + use rustls::client::danger::ServerCertVerifier; + + let sk = Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let supported = aws_lc_rs_provider().signature_verification_algorithms; + let verifier = + FingerprintPinVerifier::new(format!("ed25519:{}", hex::encode(raw_key)), supported); + let message = b"alktls tls13 raw-key signature routing"; + let signature = sk.sign(message).to_bytes().to_vec(); + let dss = dss_with_ed25519_scheme(signature); + let cert = rustls::pki_types::CertificateDer::from(spki_der.clone()); + let result = verifier.verify_tls13_signature(message, &cert, &dss); + assert!( + result.is_ok(), + "TLS 1.3 signature for an Ed25519 SPKI cert must route through the raw-key path and verify, got: {result:?}" + ); + + let forged = dss_with_ed25519_scheme(vec![0u8; 64]); + let tampered = verifier.verify_tls13_signature(b"tampered", &cert, &forged); + assert!( + tampered.is_err(), + "a signature that does not verify must fail the handshake signature check" + ); + } + + #[test] + fn fingerprint_pin_verifier_verify_tls12_signature_accepts_ed25519_raw_key() { + use rustls::client::danger::ServerCertVerifier; + + let sk = Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let supported = aws_lc_rs_provider().signature_verification_algorithms; + let verifier = + FingerprintPinVerifier::new(format!("ed25519:{}", hex::encode(raw_key)), supported); + let message = b"alktls tls12 raw-key signature routing"; + let signature = sk.sign(message).to_bytes().to_vec(); + let dss = dss_with_ed25519_scheme(signature); + let cert = rustls::pki_types::CertificateDer::from(spki_der.clone()); + let result = verifier.verify_tls12_signature(message, &cert, &dss); + assert!( + result.is_ok(), + "TLS 1.2 signature for an Ed25519 SPKI cert must route through the raw-key path and verify, got: {result:?}" + ); + } + + #[test] + fn select_server_verifier_returns_ca_verifier_for_none() { + let provider = aws_lc_rs_provider(); + let remote_identity: Option = None; + let verifier = select_server_verifier(&provider, &remote_identity); + assert!( + verifier.is_ok(), + "select_server_verifier must succeed for None (CA path)" + ); + let debug = format!("{:?}", verifier.unwrap()); + assert!( + debug.contains("WebPkiServerVerifier"), + "None must select WebPkiServerVerifier (CA verification), got: {debug}" + ); + } + + #[test] + fn select_server_verifier_returns_fingerprint_pin_for_some() { + let provider = aws_lc_rs_provider(); + let remote_identity = Some(RemoteIdentity { + fingerprint: "ed25519:abc".to_string(), + }); + let verifier = select_server_verifier(&provider, &remote_identity); + assert!( + verifier.is_ok(), + "select_server_verifier must succeed for Some (fingerprint pin path)" + ); + let debug = format!("{:?}", verifier.unwrap()); + assert!( + debug.contains("FingerprintPinVerifier"), + "Some must select FingerprintPinVerifier, got: {debug}" + ); + } + + #[test] + fn build_client_auth_presents_ed25519_raw_key_without_error() { + let provider = aws_lc_rs_provider(); + let sk = Ed25519SecretKey::generate(); + let tls_identity = Some(TlsIdentity::RawKey(sk)); + let resolver = build_client_auth(&provider, &tls_identity); + assert!( + resolver.is_ok(), + "build_client_auth must build a resolver for a RawKey identity" + ); + let resolver = resolver.unwrap(); + assert!( + resolver.only_raw_public_keys(), + "RawKey client auth resolver must present raw public keys (RFC 7250)" + ); + assert!( + resolver.has_certs(), + "RawKey client auth resolver must report it has a cert to present" + ); + } + + #[test] + fn build_client_auth_x509_loads_chain_and_presents_certs() { + let dir = tempfile::tempdir().expect("tempdir"); + let key_pair = rcgen::KeyPair::generate().expect("key gen"); + let cert = rcgen::CertificateParams::default() + .self_signed(&key_pair) + .expect("self-signed cert"); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, cert.pem()).expect("write cert"); + std::fs::write(&key_path, key_pair.serialize_pem()).expect("write key"); + + let provider = aws_lc_rs_provider(); + let tls_identity = Some(TlsIdentity::X509 { + cert: cert_path, + key: key_path, + }); + let resolver = build_client_auth(&provider, &tls_identity); + assert!( + resolver.is_ok(), + "build_client_auth must build a resolver for an X509 identity" + ); + let resolver = resolver.unwrap(); + assert!( + resolver.has_certs(), + "X509 client auth resolver must report it has a cert to present" + ); + assert!( + !resolver.only_raw_public_keys(), + "X509 client auth resolver must present X.509 certs, not raw public keys" + ); + } + + #[test] + fn build_client_auth_self_signed_resolves_to_no_client_cert() { + let provider = aws_lc_rs_provider(); + let tls_identity = Some(TlsIdentity::SelfSigned); + let resolver = build_client_auth(&provider, &tls_identity) + .expect("build_client_auth must succeed for SelfSigned"); + assert!( + !resolver.has_certs(), + "SelfSigned must present nothing (NoClientCertResolver, OQ-TLS-02)" + ); + } + + #[test] + fn build_client_auth_none_resolves_to_no_client_cert() { + let provider = aws_lc_rs_provider(); + let tls_identity: Option = None; + let resolver = build_client_auth(&provider, &tls_identity) + .expect("build_client_auth must succeed for None"); + assert!( + !resolver.has_certs(), + "NoClientCertResolver must report no certs (no client cert presented)" + ); + } + + #[test] + fn build_client_auth_acme_is_a_config_error() { + let provider = aws_lc_rs_provider(); + let tls_identity = Some(TlsIdentity::Acme { + domains: vec!["example.com".to_string()], + cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"), + directory: crate::identity::AcmeDirectory::Staging, + contact: vec!["mailto:ops@example.com".to_string()], + }); + let err = build_client_auth(&provider, &tls_identity) + .expect_err("Acme client auth must be a config error"); + assert!( + matches!(err, TlsError::AcmeConfig(_)), + "Acme local identity must map to TlsError::AcmeConfig, got: {err:?}" + ); + } + + #[test] + fn tls_client_config_pins_enable_early_data_true() { + let sk = Ed25519SecretKey::generate(); + let credentials = ConnectionCredentials::new() + .with_local_identity(TlsIdentity::RawKey(sk)) + .with_remote_identity(RemoteIdentity { + fingerprint: "ed25519:deadbeef".to_string(), + }); + let config = TlsClientConfig::new(&credentials, b"alk/call") + .expect("TlsClientConfig::new must build"); + let rustls_config = config.into_rustls_config(); + assert!( + rustls_config.enable_early_data, + "every client config must enable early data (the client half of the 0-RTT invariant, ADR-001)" + ); + assert_eq!( + rustls_config.alpn_protocols, + vec![b"alk/call".to_vec()], + "the config must carry exactly the requested ALPN" + ); + } + + #[test] + fn tls_client_config_carries_aws_lc_rs_provider() { + let sk = Ed25519SecretKey::generate(); + let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk)); + let config = TlsClientConfig::new(&credentials, b"alk/call") + .expect("TlsClientConfig::new must build"); + let rustls_config = config.into_rustls_config(); + let suites: Vec = rustls_config + .crypto_provider() + .cipher_suites + .iter() + .map(|s| s.suite()) + .collect(); + assert_eq!( + suites.len(), + 9, + "the aws-lc-rs default provider must be installed on every config (alknet ADR-084)" + ); + assert!( + suites.contains(&rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256), + "the provider must be the aws-lc-rs default set (TLS 1.3 + TLS 1.2 suites), got: {suites:?}" + ); + } + + #[test] + fn load_platform_root_cert_store_is_never_empty() { + let roots = load_platform_root_cert_store().expect("root store must load"); + assert!( + !roots.is_empty(), + "the webpki-roots fallback guarantees a non-empty root store (alknet ADR-088 §5)" + ); + assert_eq!( + roots.len(), + roots.roots.len(), + "the store's anchor count must match its backing vec" + ); + } + + #[test] + fn verifier_selection_matrix_over_client_config() { + let sk = Ed25519SecretKey::generate(); + + let pinned = ConnectionCredentials::new() + .with_local_identity(TlsIdentity::RawKey(sk.clone())) + .with_remote_identity(RemoteIdentity { + fingerprint: "ed25519:deadbeef".to_string(), + }); + let config = + TlsClientConfig::new(&pinned, b"alk/call").expect("pinned path must construct"); + let debug = format!("{:?}", config.rustls_config); + assert!( + debug.contains("FingerprintPinVerifier"), + "Some(remote_identity) must install FingerprintPinVerifier, got: {debug}" + ); + + let ca_path = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk)); + let config = TlsClientConfig::new(&ca_path, b"alk/call").expect("CA path must construct"); + let debug = format!("{:?}", config.rustls_config); + assert!( + debug.contains("WebPkiServerVerifier"), + "None(remote_identity) must install WebPkiServerVerifier, got: {debug}" + ); + } + + #[test] + fn client_auth_presentation_matrix_over_client_config() { + let sk = Ed25519SecretKey::generate(); + let dir = tempfile::tempdir().expect("tempdir"); + let key_pair = rcgen::KeyPair::generate().expect("key gen"); + let cert = rcgen::CertificateParams::default() + .self_signed(&key_pair) + .expect("self-signed cert"); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, cert.pem()).expect("write cert"); + std::fs::write(&key_path, key_pair.serialize_pem()).expect("write key"); + + let matrix: Vec<(ConnectionCredentials, &str)> = vec![ + ( + ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk.clone())), + "raw-key", + ), + ( + ConnectionCredentials::new().with_local_identity(TlsIdentity::X509 { + cert: cert_path, + key: key_path, + }), + "x509", + ), + ( + ConnectionCredentials::new().with_local_identity(TlsIdentity::SelfSigned), + "self-signed", + ), + (ConnectionCredentials::new(), "none"), + ]; + for (credentials, label) in matrix { + let config = TlsClientConfig::new(&credentials, b"alk/call") + .unwrap_or_else(|e| panic!("{label} presentation must construct: {e}")); + let resolver = &config.rustls_config.client_auth_cert_resolver; + match label { + "raw-key" => { + assert!( + resolver.has_certs() && resolver.only_raw_public_keys(), + "RawKey must present a raw public key (RFC 7250)" + ); + } + "x509" => { + assert!( + resolver.has_certs() && !resolver.only_raw_public_keys(), + "X509 must present the loaded cert chain" + ); + } + _ => { + assert!( + !resolver.has_certs(), + "{label} must present nothing (NoClientCertResolver)" + ); + } + } + } + + let acme = ConnectionCredentials::new().with_local_identity(TlsIdentity::Acme { + domains: vec!["example.com".to_string()], + cache_dir: dir.path().to_path_buf(), + directory: crate::identity::AcmeDirectory::Production, + contact: vec![], + }); + let err = match TlsClientConfig::new(&acme, b"alk/call") { + Ok(_) => panic!("Acme client auth must fail config construction"), + Err(e) => e, + }; + assert!( + matches!(err, TlsError::AcmeConfig(_)), + "Acme local identity must map to TlsError::AcmeConfig, got: {err:?}" + ); + } + + #[cfg(feature = "noq")] + #[test] + fn build_noq_client_config_with_raw_key_identity_builds_without_error() { + let sk = Ed25519SecretKey::generate(); + let credentials = ConnectionCredentials::new() + .with_local_identity(TlsIdentity::RawKey(sk)) + .with_remote_identity(RemoteIdentity { + fingerprint: "ed25519:deadbeef".to_string(), + }); + let config = TlsClientConfig::new(&credentials, b"alk/call") + .expect("TlsClientConfig::new must build"); + let noq_config = config.for_noq().expect("for_noq must convert"); + let _ = noq_config; + } + + #[cfg(feature = "noq")] + #[test] + fn build_noq_client_config_with_no_remote_identity_builds_without_error() { + let sk = Ed25519SecretKey::generate(); + let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk)); + let config = TlsClientConfig::new(&credentials, b"alk/call") + .expect("TlsClientConfig::new must build for CA-verification path"); + let noq_config = config.for_noq().expect("for_noq must convert"); + let _ = noq_config; + } +} diff --git a/src/credentials.rs b/src/credentials.rs index 12b2c54..97efb9b 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,3 +1,109 @@ //! Transport-level credential bundle for outbound connections: //! [`ConnectionCredentials`], [`RemoteIdentity`] (ADR-005, moved from -//! alknet-core `credentials.rs`). +//! alknet-core `credentials.rs`; alknet ADR-091's semantics). +//! +//! [`ConnectionCredentials`] carries the two dimensions the client config +//! consumes: the local node's identity and the expected remote identity. +//! It is transport-agnostic — consumed by this crate's +//! [`TlsClientConfig`](crate::TlsClientConfig) (TLS setup) and by the dial +//! seam in the consumers. + +use crate::identity::TlsIdentity; + +/// Expected identity of the remote node (alknet ADR-017 §7, extended by +/// alknet ADR-034 §2; ADR-005 moved the type here). +/// +/// Carries a fingerprint string the assembly layer derives when the local +/// node knows the remote (the known-peer case → fingerprint pin). +/// +/// `remote_identity: None` is the **public X.509 endpoint** case: the local +/// node has no prior knowledge of the remote, so there is no fingerprint to +/// pin. Combined with an X.509 remote, `None` selects CA verification +/// ([`WebPkiServerVerifier`] per the verifier-selection rule in alknet +/// ADR-034 §3). Combined with an Ed25519 raw-key remote, `None` fails closed +/// at handshake (raw-key remotes are always known peers — no CA to fall +/// back to). +/// +/// The `Option` is therefore load-bearing, not cosmetic: +/// `Some(fingerprint)` means "pin this" (known peer), `None` means "trust +/// the CA or fail" (unknown remote). An implementer must not default +/// `remote_identity` to a placeholder value to "satisfy" the field — `None` +/// is a real state that drives verifier selection. +/// +/// [`WebPkiServerVerifier`]: rustls::client::WebPkiServerVerifier +#[derive(Debug, Clone)] +pub struct RemoteIdentity { + /// The pinned fingerprint: `ed25519:` for raw-key remotes, + /// `SHA256:` for X.509 cert remotes (alknet ADR-030 §6). + pub fingerprint: String, +} + +/// Credentials for an outbound connection (alknet ADR-091's semantics, +/// ADR-005 moved the type here). All dimensions come from the assembly +/// layer's configuration — never from environment variables. +/// +/// The two `Option`s are the two credential dimensions the client config +/// consumes (see `docs/architecture/client.md`): the client-auth +/// presentation (`local_identity`) and the verifier selection +/// (`remote_identity`). Both are load-bearing, not cosmetic — `Some` means +/// "pin this", `None` means "trust the CA or fail", never a placeholder +/// default. +#[derive(Debug, Clone, Default)] +pub struct ConnectionCredentials { + /// The local node's identity (RFC 7250 raw key or X.509), derived from + /// the vault at startup. + pub local_identity: Option, + /// Expected fingerprint/cert of the remote node. `Some` → fingerprint + /// pin (known peer); `None` → CA verification for X.509 remotes, + /// fail-closed for Ed25519 raw-key remotes (alknet ADR-034 §2/§3). + /// `None` is the public-X.509-endpoint state, not a missing field — + /// must not be defaulted to a placeholder. + pub remote_identity: Option, +} + +impl ConnectionCredentials { + /// Credentials with both dimensions unset — the public-X.509-endpoint + /// baseline. + pub fn new() -> Self { + Self::default() + } + + /// Set the local identity (the client-auth presentation). + pub fn with_local_identity(mut self, local_identity: TlsIdentity) -> Self { + self.local_identity = Some(local_identity); + self + } + + /// Set the remote identity (the fingerprint pin). + pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self { + self.remote_identity = Some(remote); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connection_credentials_builder_methods() { + let creds = ConnectionCredentials::new().with_remote_identity(RemoteIdentity { + fingerprint: "SHA256:abc".to_string(), + }); + assert_eq!( + creds.remote_identity.as_ref().unwrap().fingerprint, + "SHA256:abc" + ); + assert!(creds.local_identity.is_none()); + } + + #[test] + fn connection_credentials_none_is_load_bearing_not_defaulted() { + let creds = ConnectionCredentials::new(); + assert!( + creds.remote_identity.is_none(), + "ConnectionCredentials::new() must keep remote_identity as None (the load-bearing \ + public-X.509-endpoint state), not default it to a placeholder" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 015ac16..68cc9f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,9 +37,16 @@ pub use signing::Ed25519SigningKey; pub use identity::{AcmeDirectory, Ed25519SecretKey, TlsIdentity}; -// The remaining re-export lines land per module as each port task -// completes: credentials, fingerprint, server, client, pem, signing -// (port-client completes the block). +pub use client::{ + build_client_auth, load_platform_root_cert_store, select_server_verifier, + FingerprintPinVerifier, NoClientCertResolver, RawKeyClientCertResolver, TlsClientConfig, +}; +pub use credentials::{ConnectionCredentials, RemoteIdentity}; + +pub use server::{ + build_rustls_server_config, generate_self_signed_cert, AcceptAnyCertVerifier, + RawKeyCertResolver, SelfSignedCert, TlsServerConfig, +}; #[derive(Debug, thiserror::Error)] #[non_exhaustive] diff --git a/src/server.rs b/src/server.rs index c42408e..537eb3b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,614 @@ //! Server-side TLS configuration: [`TlsServerConfig`], -//! [`RawKeyCertResolver`], [`AcceptAnyCertVerifier`], [`SelfSignedCert`], +//! [`build_rustls_server_config`], [`RawKeyCertResolver`], +//! [`AcceptAnyCertVerifier`], [`SelfSignedCert`] / //! [`generate_self_signed_cert`], and the ACME path (feature `acme`). + +use std::sync::Arc; + +#[cfg(feature = "acme")] +use tracing::{debug, error, warn}; + +#[cfg(feature = "acme")] +use crate::identity::AcmeDirectory; +use crate::identity::{Ed25519SecretKey, TlsIdentity}; +use crate::signing::Ed25519SigningKey; +use crate::TlsError; + +/// Server-side TLS configuration, transport-agnostic. Built once from a +/// [`TlsIdentity`] + ALPN list, shared across transports via +/// `Arc` (not `Clone` — it holds the ACME task's `JoinHandle`). +#[allow(dead_code)] +pub struct TlsServerConfig { + pub(crate) rustls_config: rustls::ServerConfig, + #[cfg(feature = "acme")] + pub(crate) acme_handle: Option>, +} + +impl TlsServerConfig { + /// Build a server config from a [`TlsIdentity`] and ALPN list. + /// ACME identities spawn a background cert-renewal task. + pub 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(TlsError::AcmeConfig( + "ACME feature not enabled but TlsIdentity::Acme configured".to_string(), + )) + } + } + _ => { + let server_config = build_rustls_server_config(tls_identity, alpns)?; + Ok(Self { + rustls_config: server_config, + #[cfg(feature = "acme")] + acme_handle: None, + }) + } + } + } + + #[cfg(feature = "acme")] + async fn new_acme( + domains: &[String], + cache_dir: &std::path::Path, + directory: &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()? + .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 { + rustls_config: config, + acme_handle: Some(handle), + }) + } + + /// Wrap the inner rustls config for the noq QUIC transport. + /// + /// Takes `&self` (ADR-004): the inner rustls config is `Clone` + /// (Arc-shared resolvers), so one [`TlsServerConfig`] can feed a noq + /// endpoint and a TCP+TLS acceptor without contortions. + #[cfg(feature = "noq")] + pub fn for_noq(&self) -> Result { + use noq::crypto::rustls::QuicServerConfig; + let quic_server_config = QuicServerConfig::try_from(self.rustls_config.clone())?; + Ok(noq::ServerConfig::with_crypto(Arc::new(quic_server_config))) + } + + /// Wrap the inner rustls config for the TCP+TLS transport. Infallible + /// — `TlsAcceptor::from(Arc)` cannot fail. + #[cfg(feature = "tcp")] + pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor { + tokio_rustls::TlsAcceptor::from(Arc::new(self.rustls_config.clone())) + } + + /// Borrow the inner config for transport wrappers the crate does not + /// cover. + pub fn rustls_config(&self) -> &rustls::ServerConfig { + &self.rustls_config + } +} + +/// Build the inner `rustls::ServerConfig` for the non-ACME identity +/// variants: `X509`, `RawKey`, `SelfSigned`. The `Acme` arm is defensive — +/// ACME is dispatched by [`TlsServerConfig::new`] to `new_acme` and +/// reaching the builder with it is a config error. +pub fn build_rustls_server_config( + tls_identity: &TlsIdentity, + 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 = crate::pem::load_cert_chain(cert)?; + let private_key = crate::pem::load_private_key(key)?; + let mut config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()? + .with_client_cert_verifier(client_verifier) + .with_single_cert(cert_chain, private_key)?; + config.alpn_protocols = alpns.to_vec(); + config.max_early_data_size = u32::MAX; + Ok(config) + } + 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()? + .with_client_cert_verifier(client_verifier) + .with_cert_resolver(resolver); + config.alpn_protocols = alpns.to_vec(); + config.max_early_data_size = u32::MAX; + Ok(config) + } + TlsIdentity::SelfSigned => { + let cert = generate_self_signed_cert()?; + let mut config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()? + .with_client_cert_verifier(client_verifier) + .with_single_cert(cert.cert_chain, cert.private_key)?; + config.alpn_protocols = alpns.to_vec(); + config.max_early_data_size = u32::MAX; + Ok(config) + } + TlsIdentity::Acme { .. } => Err(TlsError::AcmeConfig( + "TlsIdentity::Acme is handled by TlsServerConfig::new_acme, not \ + build_rustls_server_config" + .to_string(), + )), + } +} + +/// The cert material behind the [`TlsIdentity::SelfSigned`] server path: +/// a single rcgen-generated DER cert plus its PKCS#8 private key. +pub struct SelfSignedCert { + /// The self-signed leaf certificate (DER). + pub cert_chain: Vec>, + /// The matching private key (PKCS#8 DER). + pub private_key: rustls::pki_types::PrivateKeyDer<'static>, +} + +/// Generate a self-signed dev certificate (rcgen), in-memory. +pub fn generate_self_signed_cert() -> Result { + use rcgen::{CertificateParams, KeyPair}; + let key_pair = KeyPair::generate()?; + let params = CertificateParams::default(); + let cert = params.self_signed(&key_pair)?; + let cert_der = cert.der().clone(); + let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()), + ); + Ok(SelfSignedCert { + cert_chain: vec![cert_der], + private_key: key_der, + }) +} + +/// Server-side "request-but-don't-require" client cert verifier +/// (alknet ADR-034). +/// +/// Asks for a client TLS cert (X.509 or RFC 7250 raw key) so the endpoint +/// can extract the fingerprint via `peer_identity()`, but does not require +/// one and does not verify the presented cert against a CA. The TLS crate +/// hands over the fingerprint string; matching it against peer records +/// (`IdentityProvider::resolve_from_fingerprint`) is the auth layer's +/// concern, outside this crate (alktls ADR-005). +/// +/// **Server-side only.** This must not be reused as a client-side +/// `ServerCertVerifier` — client-side verification is alknet ADR-034's +/// selection matrix (see the client module): CA verification for unknown +/// X.509 remotes, fingerprint pinning for known peers, fail closed for +/// unknown raw keys. +pub struct AcceptAnyCertVerifier; + +impl std::fmt::Debug for AcceptAnyCertVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcceptAnyCertVerifier").finish() + } +} + +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, + ] + } +} + +/// Presents an [`Ed25519SecretKey`] as an RFC 7250 raw public key server +/// certificate: the SPKI DER (Ed25519 OID + 32-byte key) is the "cert", +/// `only_raw_public_keys() == true`, and the signing key is the shared +/// [`Ed25519SigningKey`] helper. +pub struct RawKeyCertResolver { + key: Arc, +} + +impl RawKeyCertResolver { + pub fn new(secret_key: &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()); + let certified_key = rustls::sign::CertifiedKey::new(vec![cert], signing_key); + Self { + key: Arc::new(certified_key), + } + } +} + +impl rustls::server::ResolvesServerCert for RawKeyCertResolver { + fn resolve( + &self, + _client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + Some(Arc::clone(&self.key)) + } + + fn only_raw_public_keys(&self) -> bool { + true + } +} + +impl std::fmt::Debug for RawKeyCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawKeyCertResolver").finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_key_cert_resolver_only_raw_public_keys() { + use rustls::server::ResolvesServerCert; + let sk = Ed25519SecretKey::generate(); + let resolver = RawKeyCertResolver::new(&sk); + assert!(resolver.only_raw_public_keys()); + } + + #[test] + fn self_signed_cert_generation_produces_cert_and_key() { + let cert = generate_self_signed_cert().expect("self-signed cert generates"); + assert!(!cert.cert_chain.is_empty()); + assert!(!cert.private_key.secret_der().is_empty()); + } + + #[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 = TlsServerConfig::new(&tls_identity, &[b"alktls/test".to_vec()]) + .await + .expect("X509 tls setup should succeed"); + let _ = setup.rustls_config; + #[cfg(feature = "acme")] + assert!(setup.acme_handle.is_none()); + } + + #[test] + fn build_rustls_server_config_raw_key_succeeds() { + let sk = Ed25519SecretKey::generate(); + let identity = TlsIdentity::RawKey(sk); + let alpns = vec![b"alktls/test".to_vec(), b"alktls/call".to_vec()]; + let config = build_rustls_server_config(&identity, &alpns).expect("raw key config builds"); + assert_eq!(config.alpn_protocols, alpns); + assert_eq!(config.max_early_data_size, u32::MAX); + } + + #[test] + fn build_rustls_server_config_self_signed_succeeds() { + let identity = TlsIdentity::SelfSigned; + let alpns = vec![b"alktls/test".to_vec()]; + let config = + build_rustls_server_config(&identity, &alpns).expect("self-signed config builds"); + assert_eq!(config.alpn_protocols, alpns); + assert_eq!(config.max_early_data_size, u32::MAX); + } + + #[test] + fn build_rustls_server_config_acme_returns_config_error() { + let identity = TlsIdentity::Acme { + domains: vec!["example.com".to_string()], + cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"), + directory: crate::identity::AcmeDirectory::Staging, + contact: vec!["mailto:dev@example.com".to_string()], + }; + let err = build_rustls_server_config(&identity, &[]) + .expect_err("Acme identity must not reach the plain builder"); + assert!( + matches!(err, TlsError::AcmeConfig(_)), + "the defensive Acme arm must surface as TlsError::AcmeConfig, got {err:?}" + ); + } + + #[cfg(feature = "noq")] + #[test] + fn for_noq_round_trips_raw_key_config() { + let sk = Ed25519SecretKey::generate(); + let rustls_config = + build_rustls_server_config(&TlsIdentity::RawKey(sk), &[b"alktls/test".to_vec()]) + .expect("rustls config builds"); + let config = TlsServerConfig { + rustls_config, + #[cfg(feature = "acme")] + acme_handle: None, + }; + let noq_config = config.for_noq().expect("noq config converts"); + let _ = noq_config; + } + + #[test] + fn accept_any_cert_verifier_offers_and_does_not_require_client_auth() { + use rustls::server::danger::ClientCertVerifier; + let verifier = AcceptAnyCertVerifier; + assert!(verifier.offer_client_auth()); + assert!(!verifier.client_auth_mandatory()); + assert!(verifier.root_hint_subjects().is_empty()); + } + + #[test] + fn accept_any_cert_verifier_verifies_any_client_cert() { + use rustls::pki_types::{CertificateDer, UnixTime}; + use rustls::server::danger::ClientCertVerifier; + let verifier = AcceptAnyCertVerifier; + let cert = CertificateDer::from(b"fake-cert-der".to_vec()); + let result = verifier.verify_client_cert(&cert, &[], UnixTime::now()); + assert!( + result.is_ok(), + "AcceptAnyCertVerifier must accept any client cert" + ); + } + + #[test] + fn accept_any_cert_verifier_supported_schemes_are_the_nine_pinned() { + use rustls::server::danger::ClientCertVerifier; + let verifier = AcceptAnyCertVerifier; + let schemes = verifier.supported_verify_schemes(); + assert_eq!( + schemes, + 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, + ] + ); + } + + #[test] + fn accept_any_cert_verifier_debug_is_implemented() { + let verifier = AcceptAnyCertVerifier; + let s = format!("{verifier:?}"); + assert!(s.contains("AcceptAnyCertVerifier")); + } + + #[test] + fn raw_key_cert_resolver_debug_is_implemented() { + let sk = Ed25519SecretKey::generate(); + let resolver = RawKeyCertResolver::new(&sk); + let s = format!("{resolver:?}"); + assert!(s.contains("RawKeyCertResolver")); + } + + #[cfg(feature = "tcp")] + #[tokio::test] + async fn new_x509_for_tcp_tls_and_rustls_config_round_trip() { + use rcgen::{CertificateParams, KeyPair}; + let key_pair = KeyPair::generate().unwrap(); + let cert = CertificateParams::default().self_signed(&key_pair).unwrap(); + 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_pair.serialize_pem()).unwrap(); + let identity = TlsIdentity::X509 { + cert: cert_path, + key: key_path, + }; + let alpn = vec![b"alktls/test".to_vec()]; + let setup = TlsServerConfig::new(&identity, &alpn) + .await + .expect("X509 config builds"); + let rc = setup.rustls_config(); + assert_eq!(rc.alpn_protocols, alpn); + assert_eq!(rc.max_early_data_size, u32::MAX); + let _acceptor = setup.for_tcp_tls(); + } + + #[cfg(feature = "tcp")] + #[tokio::test] + async fn new_raw_key_for_tcp_tls_and_rustls_config_round_trip() { + let identity = TlsIdentity::RawKey(Ed25519SecretKey::generate()); + let alpn = vec![b"alktls/test".to_vec()]; + let setup = TlsServerConfig::new(&identity, &alpn) + .await + .expect("raw key config builds"); + let rc = setup.rustls_config(); + assert_eq!(rc.alpn_protocols, alpn); + assert_eq!(rc.max_early_data_size, u32::MAX); + let _acceptor = setup.for_tcp_tls(); + } + + #[cfg(feature = "tcp")] + #[tokio::test] + async fn new_self_signed_for_tcp_tls_and_rustls_config_round_trip() { + let identity = TlsIdentity::SelfSigned; + let alpn = vec![b"alktls/test".to_vec()]; + let setup = TlsServerConfig::new(&identity, &alpn) + .await + .expect("self-signed config builds"); + let rc = setup.rustls_config(); + assert_eq!(rc.alpn_protocols, alpn); + assert_eq!(rc.max_early_data_size, u32::MAX); + let _acceptor = setup.for_tcp_tls(); + } + + #[cfg(feature = "acme")] + #[tokio::test] + async fn new_acme_spawns_and_appends_acme_tls_alpn() { + let dir = tempfile::tempdir().unwrap(); + let identity = TlsIdentity::Acme { + domains: vec!["localhost".to_string()], + cache_dir: dir.path().join("cache"), + directory: crate::identity::AcmeDirectory::Custom( + "http://127.0.0.1:9/directory".to_string(), + ), + contact: vec!["mailto:dev@example.com".to_string()], + }; + let alpn = vec![b"alktls/test".to_vec()]; + let setup = TlsServerConfig::new(&identity, &alpn) + .await + .expect("ACME config builds without awaiting the order"); + let rc = setup.rustls_config(); + assert_eq!(rc.max_early_data_size, u32::MAX); + assert_eq!( + rc.alpn_protocols, + vec![b"alktls/test".to_vec(), b"acme-tls/1".to_vec()] + ); + assert!( + setup.acme_handle.is_some(), + "the event-loop task must be spawned and its handle stored" + ); + } + + #[cfg(not(feature = "acme"))] + #[tokio::test] + async fn new_acme_identity_without_feature_returns_config_error() { + let identity = TlsIdentity::Acme { + domains: vec!["example.com".to_string()], + cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"), + directory: crate::identity::AcmeDirectory::Staging, + contact: vec!["mailto:dev@example.com".to_string()], + }; + let err = match TlsServerConfig::new(&identity, &[]).await { + Ok(_) => panic!("Acme identity must fail without the acme feature"), + Err(e) => e, + }; + assert!( + matches!(err, TlsError::AcmeConfig(_)), + "expected TlsError::AcmeConfig, got {err:?}" + ); + } +} diff --git a/tasks/port-client.md b/tasks/port-client.md index a4afb0f..826b85e 100644 --- a/tasks/port-client.md +++ b/tasks/port-client.md @@ -1,7 +1,7 @@ --- id: port-client name: Port client side — TlsClientConfig, verifier selection, client auth (src/client.rs) -status: pending +status: completed depends_on: [port-identity-types, port-fingerprint, port-pem-signing] scope: broad risk: medium @@ -73,24 +73,24 @@ co-location prevents semantic drift (ADR-005). ## Verification -- [ ] Selection-matrix test: all four client-auth presentations × both +- [x] Selection-matrix test: all four client-auth presentations × both verifier branches construct and select the expected resolver types (inspect via the config's client-auth/verifier state where the API permits; otherwise assert construction success/error kind per cell) -- [ ] `Acme` local identity → `TlsError::AcmeConfig` -- [ ] `enable_early_data == true` pinned -- [ ] Root store non-empty (fallback exercised) -- [ ] FingerprintPinVerifier unit tests ported (pin match, mismatch, +- [x] `Acme` local identity → `TlsError::AcmeConfig` +- [x] `enable_early_data == true` pinned +- [x] Root store non-empty (fallback exercised) +- [x] FingerprintPinVerifier unit tests ported (pin match, mismatch, raw-key signature routing) -- [ ] `cargo test` (default), `cargo test --all-features`, +- [x] `cargo test` (default), `cargo test --all-features`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` ## Acceptance Criteria -- [ ] `for_noq(self)` / `into_rustls_config(self)` per ADR-004 -- [ ] The selection matrix has no fourth path (fail-closed structural) -- [ ] `lib.rs` re-exports the client surface + `ConnectionCredentials` +- [x] `for_noq(self)` / `into_rustls_config(self)` per ADR-004 +- [x] The selection matrix has no fourth path (fail-closed structural) +- [x] `lib.rs` re-exports the client surface + `ConnectionCredentials` / `RemoteIdentity` ## References @@ -105,6 +105,72 @@ co-location prevents semantic drift (ADR-005). > Agent fills this during implementation. +- Verifier selection / client-auth presentation are inspected through + the `rustls::ClientConfig` Debug output (`ClientConfig` derives + `Debug` and embeds the verifier's debug name — + `FingerprintPinVerifier` / `WebPkiServerVerifier`) and the public + `client_auth_cert_resolver` field; the `verifier` field itself is + `pub(super)` at rustls 0.23.44, so the debug-string probe is the + structural assertion shape. +- `select_server_verifier`'s `WebPkiServerVerifier` build error maps to + `TlsError::VerifierBuild` via `#[from] rustls::client::VerifierBuilderError` + (the `rustls::webpki` module is private at 0.23.44; same type, + public path — lib.rs's variant doc already records this). +- The signature-routing unit tests construct `DigitallySignedStruct` + from its wire encoding through the doc-hidden + `rustls::internal::msgs` surface (`Codec::read`; `new` is + `pub(crate)`), then drive `verify_tls12_signature` / + `verify_tls13_signature` on the SPKI-as-`CertificateDer` exactly as + rustls presents it in an RFC 7250 handshake — pin match + signature + possession-proof both asserted. +- The `for_noq()` wrap relies on `TlsError::NoqWrap(#[from])` + (`NoInitialCipherSuite` → `?`), ADR-002/ADR-003. +- The extracted `TlsClientConfig`'s `#[allow(dead_code)]` is kept + (the server-side `rustls_config()` accessor shape is ADR-004's; + no consumer touches the field within this crate yet). + ## Summary -> Agent fills this on completion. \ No newline at end of file +> Agent fills this on completion. + +Ported `src/credentials.rs` (wholesale from alknet-core, doc comments +rewired to this crate's ADRs — ADR-005, alknet ADR-091/034/030 +semantics preserved verbatim in substance) and `src/client.rs` (port of +alknet-tls `client.rs` with the task's error-mapping deltas and import +rewires). Visibility: `RawKeyClientCertResolver`, `NoClientCertResolver`, +`FingerprintPinVerifier` made `pub` (lib.rs re-exports them); +`build_client_auth`, `select_server_verifier`, `load_platform_root_cert_store` +`pub`. `lib.rs` gained the client + credentials re-export lines; the +pending-modules comment now notes only the server line remains (its port +task owns it). + +Deltas vs the extraction: + +- `TlsError` mapping per ADR-002: `with_safe_default_protocol_versions` / + `CertifiedKey::from_der` / `RootCertStore::add` errors → + `TlsError::Rustls` (`#[from] rustls::Error`); the + `WebPkiServerVerifier` build error → `TlsError::VerifierBuild` + (`#[from] rustls::client::VerifierBuilderError`); the Acme + client-auth error string → `TlsError::AcmeConfig`. No + `Config(String)` catch-all. +- `for_quinn` → `for_noq` (ADR-003/004): consuming, noq-gated; the + wrap error flows through `TlsError::NoqWrap(#[from])` — no + `map_err` stringification. +- Imports rewired to `crate::{credentials, fingerprint, identity}`; + the `PeerEntry` reference in the extraction's `NoClientCertResolver` + doc became the peer-id resolution language (auth layer stays out, + ADR-005). +- Invariants pinned by tests: `enable_early_data = true` + exact ALPN; + aws-lc-rs default provider (9-suite set + `crypto_provider()` + identity); root store non-empty; verifier-selection matrix + (`Some` → `FingerprintPinVerifier`, `None` → `WebPkiServerVerifier`); + client-auth presentation matrix (RawKey → RFC 7250 raw pub keys, + X509 → loaded chain, SelfSigned/None → nothing, Acme → + `TlsError::AcmeConfig`); FingerprintPinVerifier pin match/mismatch + (Ed25519 SPKI + SHA256 X.509) and raw-key signature routing + (TLS 1.2 + 1.3, forged-signature rejection). + +Verification: `cargo test` (56 pass), `cargo test --all-features` +(59 pass, incl. both `for_noq` tests), `cargo clippy --all-targets +--all-features -- -D warnings` (clean), `cargo fmt --check` (clean), +`cargo check --features noq` / `--features tcp` / default (clean). \ No newline at end of file diff --git a/tasks/port-server.md b/tasks/port-server.md index 75163fa..25ecb0e 100644 --- a/tasks/port-server.md +++ b/tasks/port-server.md @@ -1,7 +1,7 @@ --- id: port-server name: Port server side — TlsServerConfig, resolvers, ACME path (src/server.rs) -status: pending +status: completed depends_on: [port-identity-types, port-pem-signing] scope: broad risk: medium @@ -70,25 +70,25 @@ Without the feature, `Acme` identities return `TlsError::AcmeConfig`. ## Verification -- [ ] Invariant pins green: `max_early_data_size` per path, +- [x] Invariant pins green: `max_early_data_size` per path, nine-scheme exact list, resolver behavior, verifier behavior -- [ ] ACME branch (with `--features acme`): spawns, returns +- [x] ACME branch (with `--features acme`): spawns, returns immediately, appends `acme-tls/1` (test with a staging URL + tempdir cache; do NOT hit Let's Encrypt — construct and assert config state, assert the ALPN list) -- [ ] `Acme` identity without the `acme` feature → `TlsError::AcmeConfig` -- [ ] `for_noq` / `for_tcp_tls` round-trip per identity variant +- [x] `Acme` identity without the `acme` feature → `TlsError::AcmeConfig` +- [x] `for_noq` / `for_tcp_tls` round-trip per identity variant (construction-level; handshakes are out of scope) -- [ ] `cargo test` (default), `cargo test --all-features`, +- [x] `cargo test` (default), `cargo test --all-features`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` ## Acceptance Criteria -- [ ] All five server invariants are test-asserted (not just compiled) -- [ ] `for_noq`, `for_tcp_tls`, `rustls_config` exist with ADR-004 +- [x] All five server invariants are test-asserted (not just compiled) +- [x] `for_noq`, `for_tcp_tls`, `rustls_config` exist with ADR-004 signatures (`&self`; `for_tcp_tls` infallible) -- [ ] No `unreachable!`/panics in library code -- [ ] `lib.rs` re-exports the server surface +- [x] No `unreachable!`/panics in library code +- [x] `lib.rs` re-exports the server surface ## References @@ -99,8 +99,64 @@ Without the feature, `Acme` identities return `TlsError::AcmeConfig`. ## Notes -> Agent fills this during implementation. +- **The extracted `unreachable!` is now the defensive arm**: the Acme + arm in `build_rustls_server_config` returns + `TlsError::AcmeConfig("TlsIdentity::Acme is handled by + TlsServerConfig::new_acme, not build_rustls_server_config")` — + test-asserted (`build_rustls_server_config_acme_returns_config_error`). +- **Error mapping**: every extraction `TlsError::Config(e.to_string())` + site at a `rustls::Error` producer (`with_safe_default_protocol_versions`, + `with_single_cert`) became `?` into `TlsError::Rustls` (`#[from]`); + rcgen sites became `?` into `TlsError::SelfSigned` (`#[from] + rcgen::Error`). The non-acme `TlsServerConfig::new` Acme arm keeps its + message verbatim but under `AcmeConfig`. +- **The ACME test uses a `Custom("http://127.0.0.1:9/directory")` + directory rather than the staging URL** — never contacted either way + (port 9 discard; no network I/O beyond rustls-acme's async task start); + the task's "staging URL" intent (do-not-hit-Let's-Encrypt) is satisfied + with a stronger guarantee: a blackhole address. Staging would risk a + real contact if a test misfired; `127.0.0.1:9` cannot succeed. +- **`for_noq(&self)` clones the inner config** (`try_from` consumes) — + the ADR-004 borrow shape requires it; the clone is cheap (Arc-shared + resolvers). +- **Cargo.toml deltas (two, both required)**: + 1. `noq` gains `"aws-lc-rs"` in its feature list. ADR-003's TOML + block (`features = ["rustls"]`) was written against noq 1.2's + API, but the lockfile resolves `noq-proto 1.3.0`, where + `ServerConfig::with_crypto(crypto)` (the single-arg constructor + `for_noq` calls) is `#[cfg(any(feature = "aws-lc-rs", + feature = "ring"))]` — the retry-token key comes from noq's + `ring_like` module, which needs one of the two provider features. + `"aws-lc-rs"` (not `"ring"`) matches the crate's provider posture; + it does not fight ADR-084: the config's internal provider is still + the crate's explicit `aws_lc_rs::default_provider()` (noq consumes + it from the config — ADR-003's provider paragraph). One new + lockfile line (`aws-lc-rs` under noq-proto). ADR-003 needs a + one-line amendment (recorded for review-impl docs sync). + 2. `acme = ["dep:rustls-acme", "dep:futures"]` — ADR-006 §Feature + gates already records this ("the `acme` feature gates ... the + spawned task + the `futures` dep"); the scaffold's TOML omitted + the `futures` half. Without it, `--features acme` alone fails to + compile (the spawned task uses `futures::StreamExt`). ## Summary -> Agent fills this on completion. \ No newline at end of file +Ported `src/server.rs` (614 lines) from alknet-tls server.rs with all +five ADR-pinned deltas applied: `for_noq(&self)` (borrow + clone + +`NoqWrap` via `#[from]`), `for_tcp_tls(&self)` adopted (infallible +TlsAcceptor), `rustls_config(&self)` adopted, `acme_handle` renamed, +Acme arm → `TlsError::AcmeConfig` (no `unreachable!`). All error sites +remapped to the typed variants (`Rustls`/`SelfSigned`/`AcmeConfig`). +The ACME path is verbatim (DirCache + directory URL + contacts + +resolver + `acme-tls/1` + spawned event loop with the extracted +tracing lines, returns immediately, handle never aborted). + +14 tests: 12 ported (with the Acme-unreachable → AcmeConfig and +for_quinn → for_noq rewires; the acme_directory URL tests live in +identity.rs now) + the nine-scheme exact-list pin + ACME +spawn/return/ALPN assertion + no-feature AcmeConfig test + +`for_tcp_tls`/`rustls_config` round-trips for X509 (rcgen PEM pair in +tempdir)/RawKey/SelfSigned. Verification: `cargo test` 68 pass, +`--all-features` 75 pass, `--features noq` 72 pass, clippy -D +warnings (default + all-features), `fmt --check`, feature checks +(tcp/acme) — all green. \ No newline at end of file