diff --git a/Cargo.lock b/Cargo.lock index 337f2ce431f..c7298b1b396 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "autotls-example" +version = "0.1.0" +dependencies = [ + "futures", + "libp2p", + "libp2p-autotls", + "tokio", + "tracing-subscriber", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -501,6 +512,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1341,7 +1361,7 @@ dependencies = [ "portable-atomic", "rand 0.9.4", "rand_core 0.6.4", - "rcgen", + "rcgen 0.13.2", "ring", "rkyv", "rustls", @@ -2214,19 +2234,20 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", + "rustls-native-certs", + "rustls-platform-verifier 0.7.0", "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -2276,7 +2297,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.6.4", "system-configuration 0.6.1", "tokio", "tower-service", @@ -2524,6 +2545,32 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper", + "hyper-rustls", + "hyper-util", + "rcgen 0.14.8", + "ring", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "interceptor" version = "0.17.1" @@ -2952,6 +2999,34 @@ dependencies = [ "web-time", ] +[[package]] +name = "libp2p-autotls" +version = "0.1.0" +dependencies = [ + "arc-swap", + "base64", + "futures", + "futures-timer", + "hex-literal 0.4.1", + "hickory-resolver", + "instant-acme", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "multibase", + "rand 0.8.6", + "rcgen 0.13.2", + "reqwest 0.12.24", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "unsigned-varint", + "x509-parser 0.18.1", +] + [[package]] name = "libp2p-connection-limits" version = "0.7.0" @@ -3598,7 +3673,7 @@ dependencies = [ "libp2p-identity", "libp2p-swarm", "libp2p-yamux", - "rcgen", + "rcgen 0.13.2", "ring", "rustls", "rustls-webpki", @@ -3648,7 +3723,7 @@ dependencies = [ "multihash", "quickcheck", "rand 0.8.6", - "rcgen", + "rcgen 0.13.2", "stun", "thiserror 2.0.18", "tokio", @@ -3712,14 +3787,14 @@ dependencies = [ "libp2p-tcp", "parking_lot", "pin-project-lite", - "rcgen", + "rcgen 0.13.2", "rw-stream-sink", "soketto", "thiserror 2.0.18", "tokio", "tracing", "url", - "webpki-roots", + "webpki-roots 0.26.8", ] [[package]] @@ -5024,7 +5099,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.9", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5248,6 +5323,20 @@ dependencies = [ "yasna 0.5.2", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna 0.6.0", +] + [[package]] name = "redis" version = "0.24.0" @@ -5382,6 +5471,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -5389,6 +5480,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower 0.5.2", "tower-http 0.6.10", "tower-service", @@ -5396,6 +5488,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -5421,7 +5514,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.6.2", "serde", "serde_json", "sync_wrapper", @@ -5719,6 +5812,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" @@ -7274,6 +7388,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webrtc" version = "0.17.1" @@ -7291,7 +7414,7 @@ dependencies = [ "pem", "portable-atomic", "rand 0.9.4", - "rcgen", + "rcgen 0.13.2", "regex", "ring", "rtcp", @@ -8162,6 +8285,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry 0.8.1", + "ring", "rusticata-macros", "thiserror 2.0.18", "time", @@ -8237,6 +8361,10 @@ name = "yasna" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index d59f9e15941..08b5a9eb8c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "core", "examples/autonat", "examples/autonatv2", + "examples/autotls", "examples/browser-webrtc", "examples/chat", "examples/dcutr", @@ -37,6 +38,7 @@ members = [ "muxers/test-harness", "muxers/yamux", "protocols/autonat", + "protocols/autotls", "protocols/dcutr", "protocols/floodsub", "protocols/gossipsub", @@ -78,6 +80,7 @@ edition = "2024" libp2p = { version = "0.57.0", path = "libp2p" } libp2p-allow-block-list = { version = "0.7.0", path = "misc/allow-block-list" } libp2p-autonat = { version = "0.16.0", path = "protocols/autonat" } +libp2p-autotls = { version = "0.1.0", path = "protocols/autotls" } libp2p-connection-limits = { version = "0.7.0", path = "misc/connection-limits" } libp2p-core = { version = "0.44.0", path = "core" } libp2p-dcutr = { version = "0.15.0", path = "protocols/dcutr" } diff --git a/examples/autotls/Cargo.toml b/examples/autotls/Cargo.toml new file mode 100644 index 00000000000..897d8cd11b9 --- /dev/null +++ b/examples/autotls/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "autotls-example" +version = "0.1.0" +edition.workspace = true +publish = false +license = "MIT" + +[package.metadata.release] +release = false + +[dependencies] +futures = { workspace = true } +libp2p = { path = "../../libp2p", features = ["dns", "identify", "macros", "noise", "tls", "tcp", "tokio", "websocket", "yamux"] } +libp2p-autotls = { path = "../../protocols/autotls", features = ["tokio"] } +tokio = { version = "1.52.3", features = ["full"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[lints] +workspace = true diff --git a/examples/autotls/src/main.rs b/examples/autotls/src/main.rs new file mode 100644 index 00000000000..ab6bfe4d340 --- /dev/null +++ b/examples/autotls/src/main.rs @@ -0,0 +1,133 @@ +use std::{error::Error, net::Ipv4Addr}; + +use futures::StreamExt; +use libp2p::{ + core::{Transport, multiaddr::Protocol, transport::upgrade::Version}, + identify, + multiaddr::Multiaddr, + noise, + swarm::{NetworkBehaviour, SwarmEvent}, + tcp, tls, websocket, yamux, +}; +use libp2p_autotls::{ + self as autotls, broker::DEFAULT_FORGE_DOMAIN, encoding, storage::MemCertStore, +}; +use tracing_subscriber::EnvFilter; + +/// The plain TCP port the broker dials to verify reachability. +const TCP_PORT: u16 = 4001; +/// The Secure WebSocket port browsers connect to. +const WSS_PORT: u16 = 4002; + +#[derive(NetworkBehaviour)] +struct Behaviour { + autotls: autotls::Behaviour, + identify: identify::Behaviour, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,libp2p_autotls=debug")), + ) + .try_init() + .ok(); + + let keypair = libp2p::identity::Keypair::generate_ed25519(); + let peer_id = keypair.public().to_peer_id(); + println!("Local peer id: {peer_id}"); + println!( + "Requesting a certificate for *.{}.{DEFAULT_FORGE_DOMAIN}", + encoding::peer_id_label(peer_id) + ); + + let autotls = autotls::Behaviour::new( + &keypair, + autotls::acme::AcmeConfig::production(), + MemCertStore::new(), + ) + .await; + let resolver = autotls.certificate_resolver(); + + let mut swarm = libp2p::SwarmBuilder::with_existing_identity(keypair) + .with_tokio() + .with_tcp( + tcp::Config::default(), + (noise::Config::new, tls::Config::new), + yamux::Config::default, + )? + .with_other_transport(move |key| { + let mut ws = websocket::Config::new(tcp::tokio::Transport::new(tcp::Config::default())); + ws.set_tls_config(websocket::tls::Config::new_with_server_cert_resolver( + resolver, + )); + Ok::<_, Box>( + ws.upgrade(Version::V1Lazy) + .authenticate(noise::Config::new(key)?) + .multiplex(yamux::Config::default()), + ) + })? + .with_behaviour(|key| Behaviour { + autotls, + identify: identify::Behaviour::new(identify::Config::new( + "/ipfs/id/1.0.0".into(), + key.public(), + )), + })? + .build(); + + swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{TCP_PORT}").parse()?)?; + swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{WSS_PORT}/tls/ws").parse()?)?; + println!("Waiting for a public address; AutoTLS will request a certificate once one is found."); + + let mut has_public_address = false; + + loop { + match swarm.select_next_some().await { + SwarmEvent::NewListenAddr { address, .. } => { + println!("Listening on {address}"); + if public_tcp_addr(&address) && !has_public_address { + println!( + "Found public address {address}; obtaining a certificate from Let's \ + Encrypt via the broker (this can take a couple of minutes)..." + ); + swarm.add_external_address(address); + has_public_address = true; + } + } + SwarmEvent::ExternalAddrConfirmed { address } => { + println!("External address confirmed: {address}") + } + SwarmEvent::Behaviour(BehaviourEvent::Autotls(event)) => match event { + autotls::Event::CertificateObtained { not_after_unix } => { + println!( + "SUCCESS: obtained certificate, expires at unix timestamp {not_after_unix}" + ) + } + autotls::Event::IssuanceFailed(error) => { + println!("FAILED: certificate issuance failed: {error}") + } + }, + _ => {} + } + } +} + +fn public_tcp_addr(addr: &Multiaddr) -> bool { + let mut protocols = addr.iter(); + matches!(protocols.next(), Some(Protocol::Ip4(ip)) if is_global_ipv4(ip)) + && matches!(protocols.next(), Some(Protocol::Tcp(_))) + && protocols.next().is_none() +} + +fn is_global_ipv4(ip: Ipv4Addr) -> bool { + !(ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_multicast()) +} diff --git a/protocols/autotls/CHANGELOG.md b/protocols/autotls/CHANGELOG.md new file mode 100644 index 00000000000..0c3ea2d4ef8 --- /dev/null +++ b/protocols/autotls/CHANGELOG.md @@ -0,0 +1,4 @@ +## 0.1.0 + +- Initial release. + See [PR XXXX](https://github.com/libp2p/rust-libp2p/pull/XXXx) diff --git a/protocols/autotls/Cargo.toml b/protocols/autotls/Cargo.toml new file mode 100644 index 00000000000..dbb601ca546 --- /dev/null +++ b/protocols/autotls/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "libp2p-autotls" +edition.workspace = true +rust-version.workspace = true +description = "Automatic TLS certificate provisioning for libp2p" +version = "0.1.0" +license = "MIT" +repository = "https://github.com/libp2p/rust-libp2p" +keywords = ["peer-to-peer", "libp2p", "networking"] +categories = ["network-programming", "asynchronous"] + +[dependencies] +arc-swap = "1.7" +base64 = "0.22" +futures = { workspace = true } +futures-timer = { workspace = true } +hickory-resolver = { workspace = true, features = ["tokio"] } +instant-acme = { version = "0.8.5", default-features = false, features = ["ring", "hyper-rustls"] } +libp2p-core = { workspace = true } +libp2p-identity = { workspace = true, features = ["peerid"] } +libp2p-swarm = { workspace = true } +multibase = "0.9" +rand = { workspace = true } +rcgen = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +rustls = { version = "0.23.40", default-features = false, features = ["ring", "std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt", "time"], optional = true } +tracing = { workspace = true } +unsigned-varint = { workspace = true } +x509-parser = "0.18" + +[dev-dependencies] +hex-literal = { workspace = true } +libp2p-identity = { workspace = true, features = ["ed25519"] } +#tempfile = "3" + +[features] +tokio = ["dep:tokio"] + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true diff --git a/protocols/autotls/src/acme.rs b/protocols/autotls/src/acme.rs new file mode 100644 index 00000000000..fd22a5d7c8e --- /dev/null +++ b/protocols/autotls/src/acme.rs @@ -0,0 +1,245 @@ +use std::time::Duration; + +use hickory_resolver::{ + TokioResolver, + config::{CLOUDFLARE, ResolverConfig}, + net::runtime::TokioRuntimeProvider, + proto::rr::RData, +}; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, + NewAccount, NewOrder, OrderStatus, RetryPolicy, +}; +use libp2p_identity::Keypair; + +use crate::{ + broker::{self, BrokerClient, DEFAULT_FORGE_DOMAIN, DEFAULT_FORGE_ENDPOINT}, + cert::{self, CertKey}, + encoding, + storage::{CertStore, StoredCertificate}, +}; + +/// How long to wait for the `dns-01` `TXT` record to become visible before giving up. +const DNS_PROPAGATION_TIMEOUT: Duration = Duration::from_secs(180); + +/// Configuration for the ACME issuance flow. +#[derive(Debug, Clone)] +pub struct AcmeConfig { + /// The ACME directory URL. + pub directory_url: String, + /// Optional contact email registered with the ACME account. + pub contact_email: Option, + /// The forge domain under which the certificate is issued. + pub forge_domain: String, + /// The registration broker endpoint + pub forge_endpoint: String, + /// Optional shared secret for private broker deployments (`Forge-Authorization`). + pub forge_auth_token: Option, + /// Install a process-default `rustls` crypto provider if none is set; the ACME client needs + /// one. Set `false` if you install your own. + pub install_crypto_provider: bool, +} + +impl AcmeConfig { + /// Configuration against the Let's Encrypt production CA and the public forge broker. + pub fn production() -> Self { + Self::with_directory(LetsEncrypt::Production.url()) + } + + /// Configuration against the Let's Encrypt staging CA (untrusted certs, high rate limits). + pub fn staging() -> Self { + Self::with_directory(LetsEncrypt::Staging.url()) + } + + fn with_directory(directory_url: &str) -> Self { + Self { + directory_url: directory_url.to_owned(), + contact_email: None, + forge_domain: DEFAULT_FORGE_DOMAIN.to_owned(), + forge_endpoint: DEFAULT_FORGE_ENDPOINT.to_owned(), + forge_auth_token: None, + install_crypto_provider: true, + } + } +} + +/// A certificate obtained from the ACME flow. +#[derive(Debug, Clone)] +pub struct ObtainedCertificate { + /// The PEM-encoded certificate chain. + pub chain_pem: String, + /// The PKCS#8 PEM-encoded certificate private key. + pub key_pem: String, + /// The Unix timestamp (seconds) at which the certificate expires. + pub not_after_unix: i64, +} + +/// Errors from the issuance flow. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The ACME protocol exchange failed. + #[error("acme error: {0}")] + Acme(#[from] instant_acme::Error), + /// The broker registration failed. + #[error("broker error: {0}")] + Broker(#[from] broker::Error), + /// Handling certificate material failed. + #[error("certificate error: {0}")] + Cert(#[from] cert::Error), + /// Reading or writing storage failed. + #[error("storage error: {0}")] + Io(#[from] std::io::Error), + /// (De)serializing the ACME account credentials failed. + #[error("account credentials error: {0}")] + Credentials(#[from] serde_json::Error), + /// The order contained no usable `dns-01` authorization. + #[error("the order offered no dns-01 challenge")] + NoDns01Challenge, + /// The order did not reach the `ready` state after the challenge was validated. + #[error("the acme order did not become ready")] + OrderNotReady, + /// The `dns-01` `TXT` record did not propagate within the timeout. + #[error("timed out waiting for the dns-01 TXT record to propagate")] + DnsPropagationTimeout, + /// The DNS resolver could not be constructed. + #[error("failed to set up the DNS resolver")] + ResolverSetup, +} + +/// Obtain a wildcard certificate for the node, persisting the account and certificate via `store`. +/// +/// `public_addresses` are the node's publicly dialable transport multiaddrs that the +/// broker dials to verify reachability before publishing the challenge. +pub async fn obtain_certificate( + config: &AcmeConfig, + identity_keypair: &Keypair, + public_addresses: &[String], + store: &S, +) -> Result { + let peer_id = identity_keypair.public().to_peer_id(); + let label = encoding::peer_id_label(peer_id); + let domain = format!("*.{label}.{}", config.forge_domain); + let challenge_domain = format!("_acme-challenge.{label}.{}", config.forge_domain); + + let account = load_or_create_account(config, store).await?; + let mut order = account + .new_order(&NewOrder::new(&[Identifier::Dns(domain.clone())])) + .await?; + + let cert_key = CertKey::generate()?; + let broker = BrokerClient::new( + config.forge_endpoint.clone(), + identity_keypair.clone(), + config.forge_auth_token.clone(), + )?; + + { + let mut authorizations = order.authorizations(); + while let Some(authorization) = authorizations.next().await { + let mut authorization = authorization?; + if matches!(authorization.status, AuthorizationStatus::Valid) { + continue; + } + let mut challenge = authorization + .challenge(ChallengeType::Dns01) + .ok_or(Error::NoDns01Challenge)?; + let value = challenge.key_authorization().dns_value(); + + broker.register(&value, public_addresses).await?; + wait_for_dns_txt(&challenge_domain, &value, DNS_PROPAGATION_TIMEOUT).await?; + + challenge.set_ready().await?; + } + } + + if !matches!( + order.poll_ready(&RetryPolicy::default()).await?, + OrderStatus::Ready + ) { + return Err(Error::OrderNotReady); + } + + let csr = cert_key.certificate_signing_request(&domain)?; + order.finalize_csr(&csr).await?; + let chain_pem = order.poll_certificate(&RetryPolicy::default()).await?; + + let key_pem = cert_key.to_pkcs8_pem(); + let not_after_unix = cert::not_after_unix(&chain_pem)?; + store + .store_certificate(&StoredCertificate { + chain_pem: chain_pem.clone(), + key_pem: key_pem.clone(), + }) + .await?; + + Ok(ObtainedCertificate { + chain_pem, + key_pem, + not_after_unix, + }) +} + +async fn load_or_create_account( + config: &AcmeConfig, + store: &S, +) -> Result { + if let Some(stored) = store.load_account().await? { + let credentials: AccountCredentials = serde_json::from_str(&stored)?; + return Ok(Account::builder()?.from_credentials(credentials).await?); + } + + let contacts: Vec = config + .contact_email + .iter() + .map(|email| format!("mailto:{email}")) + .collect(); + let contacts: Vec<&str> = contacts.iter().map(String::as_str).collect(); + let new_account = NewAccount { + contact: &contacts, + terms_of_service_agreed: true, + only_return_existing: false, + }; + let (account, credentials) = Account::builder()? + .create(&new_account, config.directory_url.clone(), None) + .await?; + store + .store_account(&serde_json::to_string(&credentials)?) + .await?; + Ok(account) +} + +async fn wait_for_dns_txt(name: &str, expected: &str, timeout: Duration) -> Result<(), Error> { + // TODO: support other resolvers. + let resolver = TokioResolver::builder_with_config( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + TokioRuntimeProvider::default(), + ) + .build() + .map_err(|_| Error::ResolverSetup)?; + + let start = tokio::time::Instant::now(); + let mut delay = Duration::from_secs(1); + loop { + if let Ok(lookup) = resolver.txt_lookup(name).await { + let found = lookup.answers().iter().any(|record| { + let RData::TXT(txt) = &record.data else { + return false; + }; + let value: Vec = txt + .txt_data + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect(); + value == expected.as_bytes() + }); + if found { + return Ok(()); + } + } + if start.elapsed() >= timeout { + return Err(Error::DnsPropagationTimeout); + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(60)); + } +} diff --git a/protocols/autotls/src/behaviour.rs b/protocols/autotls/src/behaviour.rs new file mode 100644 index 00000000000..95550d899fe --- /dev/null +++ b/protocols/autotls/src/behaviour.rs @@ -0,0 +1,423 @@ +use std::{ + collections::{HashSet, VecDeque}, + net::IpAddr, + sync::Arc, + task::{Context, Poll}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use futures::{FutureExt, channel::oneshot}; +use futures_timer::Delay; +use libp2p_core::{Endpoint, Multiaddr, multiaddr::Protocol, transport::PortUse}; +use libp2p_identity::Keypair; +use libp2p_swarm::{ + ConnectionDenied, ConnectionId, ExpiredListenAddr, FromSwarm, NetworkBehaviour, NewListenAddr, + THandler, THandlerInEvent, THandlerOutEvent, ToSwarm, + behaviour::{ExternalAddrConfirmed, ExternalAddrExpired}, + dummy, +}; + +use crate::{ + AutoTlsCertResolver, + acme::{self, AcmeConfig, ObtainedCertificate}, + cert, encoding, + storage::CertStore, +}; + +/// Renew the certificate this long before it expires. +const RENEWAL_LEAD: Duration = Duration::from_secs(30 * 24 * 60 * 60); +/// Back off this long after a failed issuance before retrying. +const FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60); + +/// Events emitted by the AutoTLS [`Behaviour`]. +#[derive(Debug)] +pub enum Event { + /// A certificate was obtained (or renewed) and installed. + CertificateObtained { + /// The Unix timestamp (in seconds) at which the certificate expires. + not_after_unix: i64, + }, + /// A certificate issuance attempt failed; it will be retried after a backoff. + IssuanceFailed(acme::Error), +} + +enum State { + /// Waiting for a public address before attempting issuance. + Idle, + /// An issuance task is running. + Issuing(oneshot::Receiver>), + /// A certificate is installed; the timer fires when it is time to renew. + Active(Delay), + /// Issuance failed; the timer fires when it is time to retry. + Backoff(Delay), +} + +enum AddrUpdate { + Confirmed(Multiaddr), + Expired(Multiaddr), +} + +pub struct Behaviour { + config: AcmeConfig, + identity: Keypair, + peer_id_label: String, + store: Arc, + resolver: AutoTlsCertResolver, + + /// Confirmed public transport addresses, sent to the broker for reachability verification. + confirmed: HashSet, + /// TCP ports of the node's `/tls/ws` listen addresses. + wss_ports: HashSet, + /// The `/dns4/…/tls/ws` addresses currently advertised. + advertised: HashSet, + + state: State, + pending_events: VecDeque, + pending_addrs: VecDeque, +} + +impl Behaviour +where + S: CertStore + 'static, +{ + pub async fn new(identity: &Keypair, config: AcmeConfig, store: S) -> Self { + if config.install_crypto_provider { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + let peer_id_label = encoding::peer_id_label(identity.public().to_peer_id()); + let resolver = AutoTlsCertResolver::new(); + + let mut state = State::Idle; + if let Ok(Some(stored)) = store.load_certificate().await + && let Ok(not_after) = cert::not_after_unix(&stored.chain_pem) + && resolver.set_pem(&stored.chain_pem, &stored.key_pem).is_ok() + { + state = State::Active(renewal_delay(not_after)); + } + + Self { + config, + identity: identity.clone(), + peer_id_label, + store: Arc::new(store), + resolver, + confirmed: HashSet::new(), + wss_ports: HashSet::new(), + advertised: HashSet::new(), + state, + pending_events: VecDeque::new(), + pending_addrs: VecDeque::new(), + } + } + + /// The certificate resolver to hand to a TLS-serving transport. + pub fn certificate_resolver(&self) -> Arc { + Arc::new(self.resolver.clone()) + } + + fn start_issuance(&mut self) { + let config = self.config.clone(); + let identity = self.identity.clone(); + let store = self.store.clone(); + let addresses: Vec = self.confirmed.iter().map(Multiaddr::to_string).collect(); + tracing::debug!(addresses = ?addresses, "starting AutoTLS certificate issuance"); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let result = + acme::obtain_certificate(&config, &identity, &addresses, store.as_ref()).await; + let _ = sender.send(result); + }); + self.state = State::Issuing(receiver); + } + + /// Queue advertisement updates so the set of advertised addresses matches the + /// current public IPs and `/tls/ws` ports. Only advertises while a certificate is installed. + fn reconcile_advertised(&mut self) { + let desired = if self.resolver.is_set() { + self.desired_addresses() + } else { + HashSet::new() + }; + + for addr in desired + .difference(&self.advertised) + .cloned() + .collect::>() + { + self.advertised.insert(addr.clone()); + self.pending_addrs.push_back(AddrUpdate::Confirmed(addr)); + } + for addr in self + .advertised + .difference(&desired) + .cloned() + .collect::>() + { + self.advertised.remove(&addr); + self.pending_addrs.push_back(AddrUpdate::Expired(addr)); + } + } + + fn desired_addresses(&self) -> HashSet { + let mut addresses = HashSet::new(); + for ip in self.confirmed.iter().filter_map(extract_ip) { + for &port in &self.wss_ports { + addresses.insert(forge_wss_addr( + ip, + port, + &self.peer_id_label, + &self.config.forge_domain, + )); + } + } + addresses + } + + fn can_issue(&self) -> bool { + !self.confirmed.is_empty() && !self.wss_ports.is_empty() + } +} + +impl NetworkBehaviour for Behaviour +where + S: CertStore + 'static, +{ + type ConnectionHandler = dummy::ConnectionHandler; + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + _: ConnectionId, + _: libp2p_identity::PeerId, + _: &Multiaddr, + _: &Multiaddr, + ) -> Result, ConnectionDenied> { + Ok(dummy::ConnectionHandler) + } + + fn handle_established_outbound_connection( + &mut self, + _: ConnectionId, + _: libp2p_identity::PeerId, + _: &Multiaddr, + _: Endpoint, + _: PortUse, + ) -> Result, ConnectionDenied> { + Ok(dummy::ConnectionHandler) + } + + fn on_swarm_event(&mut self, event: FromSwarm) { + match event { + FromSwarm::ExternalAddrConfirmed(ExternalAddrConfirmed { addr }) + if extract_ip(addr).is_some() => + { + self.confirmed.insert(addr.clone()); + self.reconcile_advertised(); + } + FromSwarm::ExternalAddrExpired(ExternalAddrExpired { addr }) + if self.confirmed.remove(addr) => + { + self.reconcile_advertised(); + } + FromSwarm::NewListenAddr(NewListenAddr { addr, .. }) => { + if let Some(port) = wss_port(addr) { + self.wss_ports.insert(port); + self.reconcile_advertised(); + } + } + FromSwarm::ExpiredListenAddr(ExpiredListenAddr { addr, .. }) => { + if let Some(port) = wss_port(addr) + && self.wss_ports.remove(&port) + { + self.reconcile_advertised(); + } + } + _ => {} + } + } + + fn on_connection_handler_event( + &mut self, + _: libp2p_identity::PeerId, + _: ConnectionId, + event: THandlerOutEvent, + ) { + libp2p_core::util::unreachable(event) + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll>> { + if let Some(update) = self.pending_addrs.pop_front() { + let ev = match update { + AddrUpdate::Confirmed(addr) => ToSwarm::ExternalAddrConfirmed(addr), + AddrUpdate::Expired(addr) => ToSwarm::ExternalAddrExpired(addr), + }; + return Poll::Ready(ev); + } + if let Some(event) = self.pending_events.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + loop { + match &mut self.state { + State::Idle => { + if self.can_issue() { + self.start_issuance(); + } else { + return Poll::Pending; + } + } + State::Issuing(receiver) => match receiver.poll_unpin(cx) { + Poll::Ready(Ok(Ok(certificate))) => { + if let Err(error) = self + .resolver + .set_pem(&certificate.chain_pem, &certificate.key_pem) + { + tracing::warn!("failed to install obtained certificate: {error}"); + self.state = State::Backoff(Delay::new(FAILURE_BACKOFF)); + } else { + tracing::info!( + not_after = certificate.not_after_unix, + "obtained AutoTLS certificate" + ); + self.state = State::Active(renewal_delay(certificate.not_after_unix)); + self.pending_events.push_back(Event::CertificateObtained { + not_after_unix: certificate.not_after_unix, + }); + self.reconcile_advertised(); + } + } + Poll::Ready(Ok(Err(error))) => { + tracing::warn!("AutoTLS certificate issuance failed: {error}"); + self.state = State::Backoff(Delay::new(FAILURE_BACKOFF)); + self.pending_events.push_back(Event::IssuanceFailed(error)); + } + Poll::Ready(Err(_canceled)) => { + self.state = State::Backoff(Delay::new(FAILURE_BACKOFF)); + } + Poll::Pending => return Poll::Pending, + }, + State::Active(delay) | State::Backoff(delay) => { + if delay.poll_unpin(cx).is_ready() { + self.state = State::Idle; + } else { + return Poll::Pending; + } + } + } + + if let Some(update) = self.pending_addrs.pop_front() { + return Poll::Ready(match update { + AddrUpdate::Confirmed(addr) => ToSwarm::ExternalAddrConfirmed(addr), + AddrUpdate::Expired(addr) => ToSwarm::ExternalAddrExpired(addr), + }); + } + if let Some(event) = self.pending_events.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + } + } +} + +fn renewal_delay(not_after_unix: i64) -> Delay { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let until_expiry = (not_after_unix - now).max(0) as u64; + Delay::new(Duration::from_secs( + until_expiry.saturating_sub(RENEWAL_LEAD.as_secs()), + )) +} + +fn extract_ip(addr: &Multiaddr) -> Option { + addr.iter().find_map(|protocol| match protocol { + Protocol::Ip4(ip) => Some(IpAddr::V4(ip)), + Protocol::Ip6(ip) => Some(IpAddr::V6(ip)), + _ => None, + }) +} + +fn wss_port(addr: &Multiaddr) -> Option { + let mut port = None; + let mut tls = false; + for protocol in addr.iter() { + match protocol { + Protocol::Tcp(p) => port = Some(p), + Protocol::Tls => tls = true, + Protocol::Ws(_) if tls => return port, + Protocol::Wss(_) => return port, + _ => {} + } + } + None +} + +fn forge_wss_addr(ip: IpAddr, port: u16, peer_id_label: &str, forge_domain: &str) -> Multiaddr { + let host = format!("{}.{peer_id_label}.{forge_domain}", encoding::ip_label(ip)); + let mut addr = Multiaddr::empty(); + addr.push(match ip { + IpAddr::V4(_) => Protocol::Dns4(host.into()), + IpAddr::V6(_) => Protocol::Dns6(host.into()), + }); + addr.push(Protocol::Tcp(port)); + addr.push(Protocol::Tls); + addr.push(Protocol::Ws("/".into())); + addr +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use super::*; + + #[test] + fn extract_ip_finds_first_ip() { + let addr: Multiaddr = "/ip4/1.2.3.4/tcp/4001".parse().unwrap(); + assert_eq!( + extract_ip(&addr), + Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))) + ); + let addr: Multiaddr = "/dns4/example.com/tcp/443".parse().unwrap(); + assert_eq!(extract_ip(&addr), None); + } + + #[test] + fn wss_port_matches_tls_ws_and_wss() { + assert_eq!( + wss_port(&"/ip4/1.2.3.4/tcp/443/tls/ws".parse().unwrap()), + Some(443) + ); + assert_eq!( + wss_port(&"/ip4/1.2.3.4/tcp/443/wss".parse().unwrap()), + Some(443) + ); + assert_eq!(wss_port(&"/ip4/1.2.3.4/tcp/4001".parse().unwrap()), None); + assert_eq!(wss_port(&"/ip4/1.2.3.4/tcp/8080/ws".parse().unwrap()), None); + } + + #[test] + fn forge_wss_addr_uses_dns4_and_dns6() { + let v4 = forge_wss_addr( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 443, + "k51peer", + "libp2p.direct", + ); + assert_eq!( + v4.to_string(), + "/dns4/1-2-3-4.k51peer.libp2p.direct/tcp/443/tls/ws" + ); + let v6 = forge_wss_addr( + IpAddr::V6(Ipv6Addr::LOCALHOST), + 443, + "k51peer", + "libp2p.direct", + ); + assert_eq!( + v6.to_string(), + "/dns6/0--1.k51peer.libp2p.direct/tcp/443/tls/ws" + ); + } +} diff --git a/protocols/autotls/src/broker.rs b/protocols/autotls/src/broker.rs new file mode 100644 index 00000000000..e14fc3de63f --- /dev/null +++ b/protocols/autotls/src/broker.rs @@ -0,0 +1,206 @@ +use base64::Engine; +use serde::Serialize; + +/// Default forge domain under which certificates are issued (`libp2p.direct`). +pub const DEFAULT_FORGE_DOMAIN: &str = "libp2p.direct"; +/// Default registration broker endpoint. +pub const DEFAULT_FORGE_ENDPOINT: &str = "https://registration.libp2p.direct"; +/// Path of the challenge registration endpoint, relative to the broker endpoint. +pub const CHALLENGE_PATH: &str = "/v1/_acme-challenge"; +/// Path of the broker health endpoint (`GET` returns `204`). +pub const HEALTH_PATH: &str = "/v1/health"; + +/// The JSON body `POST`ed to the broker's `/v1/_acme-challenge` endpoint. +/// +/// The field names are lowercase on the wire (the broker rejects unknown fields, and the +/// capitalized form shown in some p2p-forge documentation is stale). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ChallengeRequest { + /// The ACME `dns-01` without padding. + pub value: String, + /// The node's publicly dialable transport multiaddrs (no relay/`p2p-circuit`). + pub addresses: Vec, +} + +/// Whether `value` is a valid `dns-01` value: unpadded base64url of a 32-byte SHA-256 digest. +/// +/// The broker rejects anything else (padded, standard base64, or not 32 bytes). +pub fn is_valid_dns01_value(value: &str) -> bool { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|digest| digest.len() == 32) +} + +#[cfg(feature = "tokio")] +pub use client::{BrokerClient, Error}; + +#[cfg(feature = "tokio")] +mod client { + use libp2p_identity::Keypair; + use reqwest::{ + StatusCode, + header::{AUTHORIZATION, WWW_AUTHENTICATE}, + }; + + use super::{CHALLENGE_PATH, ChallengeRequest}; + use crate::peer_id_auth::{self, PeerIdAuthClient}; + + /// Header carrying the optional shared secret for private broker deployments. + const FORGE_AUTHORIZATION: &str = "Forge-Authorization"; + + /// Errors from the registration broker exchange. + #[derive(Debug, thiserror::Error)] + pub enum Error { + /// The HTTP request failed. + #[error("broker request failed: {0}")] + Http(#[from] reqwest::Error), + /// Building or verifying a `libp2p-PeerID` authentication header failed. + #[error("broker authentication failed: {0}")] + Auth(#[from] peer_id_auth::Error), + /// The configured broker endpoint is not a valid URL with a host. + #[error("invalid broker endpoint")] + InvalidEndpoint, + /// The broker did not return a well-formed `WWW-Authenticate` challenge. + #[error("the broker did not initiate the authentication handshake")] + MissingChallenge, + /// The broker failed to prove its identity to us. + #[error("the broker failed to authenticate")] + ServerAuthentication, + /// The broker rejected the registration. + #[error("the broker rejected the registration: {status}: {body}")] + Rejected { + /// The HTTP status code returned by the broker. + status: StatusCode, + /// The response body. + body: String, + }, + } + + /// Client for the p2p-forge registration broker. + /// + /// Registers the ACME `dns-01` challenge value with the broker over the client-initiated + /// `libp2p-PeerID` authentication handshake, signing with the node's identity key. + pub struct BrokerClient { + http: reqwest::Client, + endpoint: String, + hostname: String, + auth: PeerIdAuthClient, + forge_auth_token: Option, + } + + impl BrokerClient { + /// Create a client for the broker at `endpoint`, authenticating with `keypair`. + pub fn new( + endpoint: String, + keypair: Keypair, + forge_auth_token: Option, + ) -> Result { + let hostname = reqwest::Url::parse(&endpoint) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .ok_or(Error::InvalidEndpoint)?; + Ok(Self { + http: reqwest::Client::builder().build()?, + endpoint, + hostname, + auth: PeerIdAuthClient::new(keypair), + forge_auth_token, + }) + } + + /// Register the `dns-01` `value` and the node's public `addresses` with the broker. + pub async fn register(&self, value: &str, addresses: &[String]) -> Result<(), Error> { + let url = format!("{}{CHALLENGE_PATH}", self.endpoint); + + let challenge_server = PeerIdAuthClient::generate_challenge(); + let public_key = self.auth.public_key_param(); + let opening = peer_id_auth::build_auth_header(&[ + ("challenge-server", challenge_server.as_str()), + ("public-key", public_key.as_str()), + ]); + let response = self + .http + .post(&url) + .header(AUTHORIZATION, opening) + .send() + .await?; + if response.status() != StatusCode::UNAUTHORIZED { + return Err(rejected(response).await); + } + + let params = response + .headers() + .get(WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .and_then(peer_id_auth::parse_auth_params) + .ok_or(Error::MissingChallenge)?; + let (Some(challenge_client), Some(server_key), Some(server_sig), Some(opaque)) = ( + params.get("challenge-client"), + params.get("public-key"), + params.get("sig"), + params.get("opaque"), + ) else { + return Err(Error::MissingChallenge); + }; + let server_public_key = peer_id_auth::decode_public_key(server_key)?; + if !self.auth.verify_server( + &challenge_server, + &server_public_key, + &self.hostname, + server_sig, + )? { + return Err(Error::ServerAuthentication); + } + + let signature = + self.auth + .sign_challenge(challenge_client, &server_public_key, &self.hostname)?; + let authorization = peer_id_auth::build_auth_header(&[ + ("opaque", opaque.as_str()), + ("sig", signature.as_str()), + ]); + let body = ChallengeRequest { + value: value.to_owned(), + addresses: addresses.to_vec(), + }; + let mut request = self + .http + .post(&url) + .header(AUTHORIZATION, authorization) + .json(&body); + if let Some(token) = &self.forge_auth_token { + request = request.header(FORGE_AUTHORIZATION, token); + } + let response = request.send().await?; + if response.status() != StatusCode::OK { + return Err(rejected(response).await); + } + Ok(()) + } + } + + async fn rejected(response: reqwest::Response) -> Error { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Error::Rejected { status, body } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dns01_value_validation() { + let valid = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]); + assert!(is_valid_dns01_value(&valid)); + + let padded = base64::engine::general_purpose::URL_SAFE.encode([0u8; 32]); + assert!(padded.ends_with('=')); + assert!(!is_valid_dns01_value(&padded)); + assert!(!is_valid_dns01_value( + &base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]) + )); + assert!(!is_valid_dns01_value("not base64!")); + } +} diff --git a/protocols/autotls/src/cert.rs b/protocols/autotls/src/cert.rs new file mode 100644 index 00000000000..75472b27479 --- /dev/null +++ b/protocols/autotls/src/cert.rs @@ -0,0 +1,104 @@ +use rcgen::{CertificateParams, DistinguishedName, KeyPair, PKCS_ECDSA_P256_SHA256}; +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use x509_parser::prelude::*; + +/// Errors produced while handling certificate material. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Generating or serializing the certificate key or CSR failed. + #[error("certificate generation failed")] + Rcgen(#[from] rcgen::Error), + /// The PEM certificate chain could not be parsed. + #[error("failed to parse PEM certificate chain")] + Pem(#[source] rustls::pki_types::pem::Error), + /// The certificate chain contained no certificates. + #[error("the certificate chain was empty")] + EmptyChain, + /// A certificate in the chain could not be parsed as X.509. + #[error("failed to parse X.509 certificate")] + X509, +} + +/// The private key used for the AutoTLS certificate, distinct from the node's identity key. +pub struct CertKey { + keypair: KeyPair, +} + +impl CertKey { + /// Generate a fresh P-256 ECDSA certificate key. + pub fn generate() -> Result { + Ok(Self { + keypair: KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?, + }) + } + + /// Load a certificate key from its PKCS#8 PEM encoding. + pub fn from_pkcs8_pem(pem: &str) -> Result { + Ok(Self { + keypair: KeyPair::from_pem(pem)?, + }) + } + + /// Serialize the certificate key as PKCS#8 PEM, for persistence. + pub fn to_pkcs8_pem(&self) -> String { + self.keypair.serialize_pem() + } + + /// Build a DER-encoded PKCS#10 certificate signing request for the given (wildcard) domain. + pub fn certificate_signing_request(&self, domain: &str) -> Result, Error> { + let mut params = CertificateParams::new(vec![domain.to_owned()])?; + params.distinguished_name = DistinguishedName::new(); + Ok(params.serialize_request(&self.keypair)?.der().to_vec()) + } +} + +/// The Unix timestamp at which the leaf certificate of the chain expires, used to +/// schedule renewal. +pub fn not_after_unix(chain_pem: &str) -> Result { + let chain = CertificateDer::pem_slice_iter(chain_pem.as_bytes()) + .collect::, _>>() + .map_err(Error::Pem)?; + let leaf = chain.first().ok_or(Error::EmptyChain)?; + let (_, certificate) = X509Certificate::from_der(leaf).map_err(|_| Error::X509)?; + Ok(certificate.validity().not_after.timestamp()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOMAIN: &str = + "*.k51qzi5uqu5diuci8bva7narzo109juvlfbckhzf3j2ljua2979b21rs6uyquk.libp2p.direct"; + + fn self_signed_chain(cert_key: &CertKey) -> String { + let params = CertificateParams::new(vec![DOMAIN.to_owned()]).unwrap(); + params.self_signed(&cert_key.keypair).unwrap().pem() + } + + #[test] + fn key_pem_round_trips() { + let key = CertKey::generate().unwrap(); + let reloaded = CertKey::from_pkcs8_pem(&key.to_pkcs8_pem()).unwrap(); + assert!(reloaded.certificate_signing_request(DOMAIN).is_ok()); + } + + #[test] + fn csr_is_valid_der() { + let key = CertKey::generate().unwrap(); + let csr = key.certificate_signing_request(DOMAIN).unwrap(); + assert!(!csr.is_empty()); + assert_eq!(csr[0], 0x30, "DER PKCS#10 must start with a SEQUENCE tag"); + } + + #[test] + fn reads_expiry() { + let key = CertKey::generate().unwrap(); + let chain = self_signed_chain(&key); + assert!(not_after_unix(&chain).unwrap() > 0); + } + + #[test] + fn empty_chain_is_rejected() { + assert!(matches!(not_after_unix(""), Err(Error::EmptyChain))); + } +} diff --git a/protocols/autotls/src/cert_resolver.rs b/protocols/autotls/src/cert_resolver.rs new file mode 100644 index 00000000000..5f9aad21fae --- /dev/null +++ b/protocols/autotls/src/cert_resolver.rs @@ -0,0 +1,72 @@ +use std::{fmt, sync::Arc}; + +use arc_swap::ArcSwapOption; +use rustls::{ + crypto::ring::sign::any_supported_type, + pki_types::{ + CertificateDer, PrivateKeyDer, + pem::{self, PemObject}, + }, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; + +/// Errors installing a certificate into the resolver. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The PEM could not be parsed. + #[error("failed to parse PEM")] + Pem(#[source] pem::Error), + /// The key PEM contained no private key. + #[error("the PEM contained no private key")] + MissingKey, + /// The private key could not be used as a `rustls` signing key. + #[error("invalid certificate signing key")] + SigningKey(#[source] rustls::Error), +} + +/// A [`ResolvesServerCert`] whose certificate can be replaced at runtime. +#[derive(Clone, Default)] +pub struct AutoTlsCertResolver(Arc>); + +impl AutoTlsCertResolver { + /// Create a resolver that serves no certificate until one is installed. + pub fn new() -> Self { + Self::default() + } + + /// Install the PEM certificate chain and PKCS#8 PEM key, replacing any previous certificate. + pub fn set_pem(&self, chain_pem: &str, key_pem: &str) -> Result<(), Error> { + let chain = CertificateDer::pem_slice_iter(chain_pem.as_bytes()) + .collect::, _>>() + .map_err(Error::Pem)?; + let key = + PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).map_err(|error| match error { + pem::Error::NoItemsFound => Error::MissingKey, + error => Error::Pem(error), + })?; + let signing_key = any_supported_type(&key).map_err(Error::SigningKey)?; + self.0 + .store(Some(Arc::new(CertifiedKey::new(chain, signing_key)))); + Ok(()) + } + + /// Whether a certificate is currently installed. + pub fn is_set(&self) -> bool { + self.0.load().is_some() + } +} + +impl fmt::Debug for AutoTlsCertResolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AutoTlsCertResolver") + .field("is_set", &self.is_set()) + .finish() + } +} + +impl ResolvesServerCert for AutoTlsCertResolver { + fn resolve(&self, _client_hello: ClientHello<'_>) -> Option> { + self.0.load_full() + } +} diff --git a/protocols/autotls/src/encoding.rs b/protocols/autotls/src/encoding.rs new file mode 100644 index 00000000000..1e946b12ffa --- /dev/null +++ b/protocols/autotls/src/encoding.rs @@ -0,0 +1,98 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use libp2p_identity::PeerId; +use multibase::Base; + +const CID_V1: u8 = 0x01; +const LIBP2P_KEY_CODEC: u8 = 0x72; + +/// Encode a [`PeerId`] as the base36 CIDv1 (`libp2p-key`) label used in `libp2p.direct` hostnames. +pub fn peer_id_label(peer_id: PeerId) -> String { + let multihash = peer_id.to_bytes(); + let mut cid = Vec::with_capacity(2 + multihash.len()); + cid.push(CID_V1); + cid.push(LIBP2P_KEY_CODEC); + cid.extend_from_slice(&multihash); + multibase::encode(Base::Base36Lower, cid) +} + +/// Encode an IP address as the dashed DNS label used in `libp2p.direct` hostnames. +pub fn ip_label(ip: IpAddr) -> String { + match ip { + IpAddr::V4(ip) => ipv4_label(ip), + IpAddr::V6(ip) => ipv6_label(ip), + } +} + +/// Encode an IPv4 address by replacing `.` with `-`, e.g. `1.2.3.4` becomes `1-2-3-4`. +pub fn ipv4_label(ip: Ipv4Addr) -> String { + ip.to_string().replace('.', "-") +} + +/// Encode an IPv6 address by replacing `:` with `-`, padding a leading or trailing `-` with `0` so +/// the result is a valid DNS label, e.g. `::1` becomes `0--1` and `2001:db8::1` becomes +/// `2001-db8--1`. +/// +/// The address is rendered in its canonical [RFC 5952](https://datatracker.ietf.org/doc/html/rfc5952) compressed form before substitution, which +/// matches the labels produced by the `p2p-forge` DNS server. +pub fn ipv6_label(ip: Ipv6Addr) -> String { + let mut label = ip.to_string().replace(':', "-"); + if label.starts_with('-') { + label.insert(0, '0'); + } + if label.ends_with('-') { + label.push('0'); + } + label +} + +#[cfg(test)] +mod tests { + use std::{net::Ipv6Addr, str::FromStr}; + + use super::*; + + #[test] + fn peer_id_label_interop() { + let vectors = [ + ( + "12D3KooWGzxzKZYveHXtpG6AsrUJBcWxHBFS2HsEoGTxrMLvKXtf", + "k51qzi5uqu5diuci8bva7narzo109juvlfbckhzf3j2ljua2979b21rs6uyquk", + ), + ( + "QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "k2k4r8jl0yz8qjgqbmc2cdu5hkqek5rj6flgnlkyywynci20j0iuyfuj", + ), + ]; + for (peer_id, expected) in vectors { + let peer_id = PeerId::from_str(peer_id).unwrap(); + assert_eq!(peer_id_label(peer_id), expected); + } + } + + #[test] + fn ipv4_label_interop() { + assert_eq!(ipv4_label(Ipv4Addr::new(1, 2, 3, 4)), "1-2-3-4"); + assert_eq!( + ipv4_label(Ipv4Addr::new(255, 255, 255, 255)), + "255-255-255-255" + ); + } + + #[test] + fn ipv6_label_interop() { + let vectors = [ + ("::1", "0--1"), + ("::", "0--0"), + ("1::", "1--0"), + ("fe80::", "fe80--0"), + ("2001:db8::1", "2001-db8--1"), + ("2001:4860:4860::8889", "2001-4860-4860--8889"), + ("a:b:c:d:1:2:3:4", "a-b-c-d-1-2-3-4"), + ]; + for (ip, expected) in vectors { + let ip = Ipv6Addr::from_str(ip).unwrap(); + assert_eq!(ipv6_label(ip), expected, "ipv6 label for {ip}"); + } + } +} diff --git a/protocols/autotls/src/lib.rs b/protocols/autotls/src/lib.rs new file mode 100644 index 00000000000..43e66e0b6d5 --- /dev/null +++ b/protocols/autotls/src/lib.rs @@ -0,0 +1,29 @@ +//! Automatic TLS certificate provisioning for libp2p (AutoTLS). +//! +//! AutoTLS lets a publicly reachable node automatically obtain a browser-trusted +//! [Let's Encrypt](https://letsencrypt.org) wildcard certificate for +//! `*..libp2p.direct`, so that browsers can dial it over Secure WebSockets (WSS). +//! +//! The certificate is obtained through the ACME `dns-01` challenge: the node proves control of +//! its `PeerId` to the `registration.libp2p.direct` broker, which then publishes the +//! `_acme-challenge` TXT record on its behalf. The `libp2p.direct` authoritative DNS encodes the +//! node's public IP directly into the queried hostname (`1-2-3-4..libp2p.direct` resolves +//! to `1.2.3.4`), so a single wildcard certificate covers every per-IP hostname and survives IP +//! changes. + +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +#[cfg(feature = "tokio")] +pub mod acme; +#[cfg(feature = "tokio")] +mod behaviour; +pub mod broker; +pub mod cert; +mod cert_resolver; +pub mod encoding; +pub mod peer_id_auth; +pub mod storage; + +#[cfg(feature = "tokio")] +pub use behaviour::{Behaviour, Event}; +pub use cert_resolver::AutoTlsCertResolver; diff --git a/protocols/autotls/src/peer_id_auth.rs b/protocols/autotls/src/peer_id_auth.rs new file mode 100644 index 00000000000..6b0108c5ada --- /dev/null +++ b/protocols/autotls/src/peer_id_auth.rs @@ -0,0 +1,262 @@ +use std::collections::HashMap; + +use base64::{ + Engine, alphabet, + engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, +}; +use libp2p_identity::{DecodingError, Keypair, PublicKey, SigningError}; +use rand::RngCore; + +const SCHEME: &str = "libp2p-PeerID"; + +const BASE64: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); + +/// Errors produced while authenticating with the `libp2p-PeerID` scheme. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Signing the challenge failed. + #[error("failed to sign challenge")] + Signing(#[from] SigningError), + /// A header value was not valid base64url. + #[error("invalid base64 value")] + Base64(#[from] base64::DecodeError), + /// A `public-key` value was not a valid protobuf-encoded public key. + #[error("invalid public key")] + PublicKey(#[from] DecodingError), +} + +/// Client for the libp2p HTTP `libp2p-PeerID` authentication scheme. +#[derive(Debug, Clone)] +pub struct PeerIdAuthClient { + keypair: Keypair, +} + +impl PeerIdAuthClient { + /// Create a client that authenticates with the given identity keypair. + pub fn new(keypair: Keypair) -> Self { + Self { keypair } + } + + /// The base64url-encoded protobuf public key, for the `public-key` header parameter. + pub fn public_key_param(&self) -> String { + BASE64.encode(self.keypair.public().encode_protobuf()) + } + + /// Generate a fresh random `challenge-server` value (base64url-encoded 32 random bytes). + pub fn generate_challenge() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + BASE64.encode(bytes) + } + + /// Produce the client's signature over `{challenge-client, server-public-key, hostname}`, + /// base64url-encoded for the `sig` header parameter. + pub fn sign_challenge( + &self, + challenge_client: &str, + server_public_key: &PublicKey, + hostname: &str, + ) -> Result { + let server_key = server_public_key.encode_protobuf(); + let data = gen_data_to_sign(&[ + ("challenge-client", challenge_client.as_bytes()), + ("server-public-key", server_key.as_slice()), + ("hostname", hostname.as_bytes()), + ]); + Ok(BASE64.encode(self.keypair.sign(&data)?)) + } + + /// Verify the server's base64url-encoded signature over + /// `{challenge-server, client-public-key, hostname}`, where `challenge_server` is the challenge + /// this client sent and the client public key is this client's own. + pub fn verify_server( + &self, + challenge_server: &str, + server_public_key: &PublicKey, + hostname: &str, + sig: &str, + ) -> Result { + let sig = BASE64.decode(sig)?; + let client_key = self.keypair.public().encode_protobuf(); + let data = gen_data_to_sign(&[ + ("challenge-server", challenge_server.as_bytes()), + ("client-public-key", client_key.as_slice()), + ("hostname", hostname.as_bytes()), + ]); + Ok(server_public_key.verify(&data, &sig)) + } +} + +/// Decode a base64url-encoded protobuf `public-key` parameter value. +pub fn decode_public_key(value: &str) -> Result { + let bytes = BASE64.decode(value)?; + Ok(PublicKey::try_decode_protobuf(&bytes)?) +} + +/// Build a `libp2p-PeerID` auth header value from the given ordered parameters. +pub fn build_auth_header(params: &[(&str, &str)]) -> String { + let mut header = String::from(SCHEME); + for (i, (name, value)) in params.iter().enumerate() { + header.push_str(if i == 0 { " " } else { ", " }); + header.push_str(name); + header.push_str("=\""); + header.push_str(value); + header.push('"'); + } + header +} + +/// Parse the parameters of a `libp2p-PeerID` auth header value, tolerating both comma- and +/// space-separated parameters. Returns `None` if the scheme prefix or quoting is malformed. +pub fn parse_auth_params(header: &str) -> Option> { + let mut rest = header.strip_prefix(SCHEME)?.trim_start(); + let mut params = HashMap::new(); + while !rest.is_empty() { + rest = rest.trim_start_matches([',', ' ', '\t']); + if rest.is_empty() { + break; + } + let eq = rest.find('=')?; + let name = rest[..eq].trim(); + let value = rest[eq + 1..].strip_prefix('"')?; + let end = value.find('"')?; + params.insert(name.to_owned(), value[..end].to_owned()); + rest = &value[end + 1..]; + } + Some(params) +} + +fn gen_data_to_sign(parts: &[(&str, &[u8])]) -> Vec { + let mut built: Vec> = parts + .iter() + .map(|(name, value)| { + let mut part = Vec::with_capacity(name.len() + 1 + value.len()); + part.extend_from_slice(name.as_bytes()); + part.push(b'='); + part.extend_from_slice(value); + part + }) + .collect(); + built.sort(); + + let mut data = SCHEME.as_bytes().to_vec(); + let mut buffer = unsigned_varint::encode::usize_buffer(); + for part in &built { + data.extend_from_slice(unsigned_varint::encode::usize(part.len(), &mut buffer)); + data.extend_from_slice(part); + } + data +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + const SERVER_PRIVKEY_PB: [u8; 68] = hex!( + "0801124001010101010101010101010101010101010101010101010101010101010101018a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c" + ); + const CLIENT_PRIVKEY_PB: [u8; 68] = hex!( + "0801124002020202020202020202020202020202020202020202020202020202020202028139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394" + ); + const CLIENT_PUBKEY_PB: [u8; 36] = + hex!("080112208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394"); + const HOSTNAME: &str = "example.com"; + const CHALLENGE: &str = "ERERERERERERERERERERERERERERERERERERERERERE="; + const SERVER_SIG: &str = + "UA88qZbLUzmAxrD9KECbDCgSKAUBAvBHrOCF2X0uPLR1uUCF7qGfLPc7dw3Olo-LaFCDpk5sXN7TkLWPVvuXAA=="; + const CLIENT_SIG: &str = + "OrwJPO4buHKJdKXP2av8PFwv3XF_-m5MqndskeVV5UzufYzBCTm7RBaFnBS1sEhuQHZSZPh9RJgN5NmLzrUrBQ=="; + + fn server_keypair() -> Keypair { + Keypair::from_protobuf_encoding(&SERVER_PRIVKEY_PB).unwrap() + } + + fn client() -> PeerIdAuthClient { + PeerIdAuthClient::new(Keypair::from_protobuf_encoding(&CLIENT_PRIVKEY_PB).unwrap()) + } + + #[test] + fn data_to_sign_matches_spec() { + let data = gen_data_to_sign(&[ + ("challenge-server", CHALLENGE.as_bytes()), + ("client-public-key", CLIENT_PUBKEY_PB.as_slice()), + ("hostname", HOSTNAME.as_bytes()), + ]); + let expected = hex!( + "6c69627032702d5065657249443d6368616c6c656e67652d7365727665723d455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d36636c69656e742d7075626c69632d6b65793d080112208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39414686f73746e616d653d6578616d706c652e636f6d" + ); + assert_eq!(data, expected); + } + + #[test] + fn server_signature_matches_spec() { + let data = gen_data_to_sign(&[ + ("challenge-server", CHALLENGE.as_bytes()), + ("client-public-key", CLIENT_PUBKEY_PB.as_slice()), + ("hostname", HOSTNAME.as_bytes()), + ]); + let sig = server_keypair().sign(&data).unwrap(); + assert_eq!(BASE64.encode(sig), SERVER_SIG); + } + + #[test] + fn client_sign_challenge_matches_spec() { + let sig = client() + .sign_challenge(CHALLENGE, &server_keypair().public(), HOSTNAME) + .unwrap(); + assert_eq!(sig, CLIENT_SIG); + } + + #[test] + fn verify_server_signature_from_spec() { + assert!( + client() + .verify_server(CHALLENGE, &server_keypair().public(), HOSTNAME, SERVER_SIG) + .unwrap() + ); + } + + #[test] + fn decode_public_key_round_trips() { + let encoded = BASE64.encode(server_keypair().public().encode_protobuf()); + assert_eq!( + decode_public_key(&encoded).unwrap(), + server_keypair().public() + ); + } + + #[test] + fn parse_auth_params_handles_padding_and_build_round_trips() { + let header = format!( + r#"libp2p-PeerID challenge-client="{CHALLENGE}", public-key="CAESIIqI4910CfGV_VLbLTy6XXLKZwm_HZQSG_N0iAG0D29c", sig="{SERVER_SIG}""# + ); + let params = parse_auth_params(&header).unwrap(); + assert_eq!(params["challenge-client"], CHALLENGE); + assert_eq!(params["sig"], SERVER_SIG); + assert_eq!( + params["public-key"], + "CAESIIqI4910CfGV_VLbLTy6XXLKZwm_HZQSG_N0iAG0D29c" + ); + + assert_eq!( + build_auth_header(&[("public-key", "CAESII"), ("sig", "abc")]), + r#"libp2p-PeerID public-key="CAESII", sig="abc""# + ); + } + + #[test] + fn generate_challenge_is_32_bytes() { + assert_eq!( + BASE64 + .decode(PeerIdAuthClient::generate_challenge()) + .unwrap() + .len(), + 32 + ); + } +} diff --git a/protocols/autotls/src/storage.rs b/protocols/autotls/src/storage.rs new file mode 100644 index 00000000000..640e2e8ddf6 --- /dev/null +++ b/protocols/autotls/src/storage.rs @@ -0,0 +1,208 @@ +// #[cfg(feature = "tokio")] +// use std::path::PathBuf; +use std::{ + future::ready, + io, + sync::{Arc, Mutex}, +}; + +/// A stored certificate: the PEM certificate chain and its PKCS#8 PEM private key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredCertificate { + /// The PEM-encoded certificate chain returned by the ACME server. + pub chain_pem: String, + /// The PKCS#8 PEM-encoded private key for the certificate. + pub key_pem: String, +} + +/// Storage for the ACME account credentials and the issued certificate. +pub trait CertStore: Send + Sync { + /// Load the serialized ACME account credentials, or `None` if none are stored. + fn load_account(&self) -> impl Future>> + Send; + /// Persist the serialized ACME account credentials. + fn store_account(&self, credentials: &str) -> impl Future> + Send; + /// Load the stored certificate, or `None` if none is stored. + fn load_certificate( + &self, + ) -> impl Future>> + Send; + /// Persist the certificate. + fn store_certificate( + &self, + certificate: &StoredCertificate, + ) -> impl Future> + Send; +} + +/// An in-memory [`CertStore`]. +#[derive(Debug, Clone, Default)] +pub struct MemCertStore { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct MemState { + account: Option, + certificate: Option, +} + +impl MemCertStore { + /// Create an empty in-memory store. + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, MemState> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +impl CertStore for MemCertStore { + fn load_account(&self) -> impl Future>> + Send { + ready(Ok(self.lock().account.clone())) + } + + fn store_account(&self, credentials: &str) -> impl Future> + Send { + self.lock().account = Some(credentials.to_owned()); + ready(Ok(())) + } + + fn load_certificate( + &self, + ) -> impl Future>> + Send { + ready(Ok(self.lock().certificate.clone())) + } + + fn store_certificate( + &self, + certificate: &StoredCertificate, + ) -> impl Future> + Send { + self.lock().certificate = Some(certificate.clone()); + ready(Ok(())) + } +} + +// /// A [`CertStore`] backed by a directory on the filesystem. +// #[cfg(feature = "tokio")] +// #[derive(Debug, Clone)] +// pub struct FileCertStore { +// directory: PathBuf, +// } +// +// #[cfg(feature = "tokio")] +// impl FileCertStore { +// /// Create a store rooted at the given directory. +// pub fn new(directory: impl Into) -> Self { +// Self { +// directory: directory.into(), +// } +// } +// } +// +// #[cfg(feature = "tokio")] +// impl CertStore for FileCertStore { +// fn load_account(&self) -> impl Future>> + Send { +// read_optional(self.directory.join("account.json")) +// } +// +// fn store_account(&self, credentials: &str) -> impl Future> + Send { +// write_file( +// self.directory.clone(), +// "account.json", +// credentials.to_owned(), +// ) +// } +// +// fn load_certificate( +// &self, +// ) -> impl Future>> + Send { +// let directory = self.directory.clone(); +// async move { +// let (Some(chain_pem), Some(key_pem)) = ( +// read_optional(directory.join("cert.pem")).await?, +// read_optional(directory.join("key.pem")).await?, +// ) else { +// return Ok(None); +// }; +// Ok(Some(StoredCertificate { chain_pem, key_pem })) +// } +// } +// +// fn store_certificate( +// &self, +// certificate: &StoredCertificate, +// ) -> impl Future> + Send { +// let directory = self.directory.clone(); +// let chain_pem = certificate.chain_pem.clone(); +// let key_pem = certificate.key_pem.clone(); +// async move { +// write_file(directory.clone(), "cert.pem", chain_pem).await?; +// write_file(directory, "key.pem", key_pem).await +// } +// } +// } +// +// #[cfg(feature = "tokio")] +// async fn write_file(directory: PathBuf, file: &'static str, contents: String) -> io::Result<()> { +// tokio::fs::create_dir_all(&directory).await?; +// tokio::fs::write(directory.join(file), contents).await +// } +// +// #[cfg(feature = "tokio")] +// async fn read_optional(path: PathBuf) -> io::Result> { +// match tokio::fs::read_to_string(path).await { +// Ok(contents) => Ok(Some(contents)), +// Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), +// Err(e) => Err(e), +// } +// } + +#[cfg(all(test, feature = "tokio"))] +mod tests { + use super::*; + + // #[tokio::test] + // async fn file_store_account_round_trips_and_missing_is_none() { + // let dir = tempfile::tempdir().unwrap(); + // let store = FileCertStore::new(dir.path()); + // + // assert_eq!(store.load_account().await.unwrap(), None); + // store.store_account("creds-blob").await.unwrap(); + // assert_eq!( + // store.load_account().await.unwrap().as_deref(), + // Some("creds-blob") + // ); + // } + // + // #[tokio::test] + // async fn file_store_certificate_round_trips_and_partial_is_none() { + // let dir = tempfile::tempdir().unwrap(); + // let store = FileCertStore::new(dir.path()); + // + // assert_eq!(store.load_certificate().await.unwrap(), None); + // let cert = StoredCertificate { + // chain_pem: "CHAIN".to_owned(), + // key_pem: "KEY".to_owned(), + // }; + // store.store_certificate(&cert).await.unwrap(); + // assert_eq!(store.load_certificate().await.unwrap(), Some(cert)); + // } + + #[tokio::test] + async fn mem_store_round_trips_and_clone_shares_state() { + let store = MemCertStore::new(); + let clone = store.clone(); + + assert_eq!(store.load_account().await.unwrap(), None); + store.store_account("creds-blob").await.unwrap(); + assert_eq!( + clone.load_account().await.unwrap().as_deref(), + Some("creds-blob") + ); + + let cert = StoredCertificate { + chain_pem: "CHAIN".to_owned(), + key_pem: "KEY".to_owned(), + }; + store.store_certificate(&cert).await.unwrap(); + assert_eq!(clone.load_certificate().await.unwrap(), Some(cert)); + } +} diff --git a/transports/websocket/CHANGELOG.md b/transports/websocket/CHANGELOG.md index a80b4fb1cb6..074e7e51349 100644 --- a/transports/websocket/CHANGELOG.md +++ b/transports/websocket/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.46.0 +- Add `tls::Config::new_with_server_cert_resolver` to resolve server certificates dynamically. + See [PR XXXX](https://github.com/libp2p/rust-libp2p/pull/XXXX). - Raise MSRV to 1.88.0. See [PR 6273](https://github.com/libp2p/rust-libp2p/pull/6273). - feat(websocket): support `/tls/sni//ws` multiaddrs in the WebSocket transport diff --git a/transports/websocket/src/tls.rs b/transports/websocket/src/tls.rs index 6d2740691d5..b2e8dd634c5 100644 --- a/transports/websocket/src/tls.rs +++ b/transports/websocket/src/tls.rs @@ -76,6 +76,15 @@ impl Config { Ok(builder.finish()) } + /// Create a new TLS configuration that resolves the server certificate dynamically. + pub fn new_with_server_cert_resolver( + resolver: Arc, + ) -> Self { + let mut builder = Config::builder(); + builder.server_with_resolver(resolver); + builder.finish() + } + /// Create a client-only configuration. pub fn client() -> Self { let provider = rustls::crypto::ring::default_provider(); @@ -130,6 +139,24 @@ impl Builder { Ok(self) } + /// Set a dynamic server certificate resolver. + /// + /// The resolver is queried for every inbound connection, so the served certificate can change + /// at runtime without rebuilding the transport. + pub fn server_with_resolver( + &mut self, + resolver: Arc, + ) -> &mut Self { + let provider = rustls::crypto::ring::default_provider(); + let server = rustls::ServerConfig::builder_with_provider(provider.into()) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_cert_resolver(resolver); + self.server = Some(server); + self + } + /// Add an additional trust anchor. pub fn add_trust(&mut self, cert: &Certificate) -> Result<&mut Self, Error> { self.client_root_store