diff --git a/Cargo.lock b/Cargo.lock index 4020ebf..5eeffd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,9 +290,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -369,6 +383,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "x509-cert", ] [[package]] @@ -573,6 +588,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -1068,6 +1089,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1687,6 +1717,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.52.3" @@ -2150,6 +2201,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2219,6 +2282,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 5e9e17b..b560087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-rustls = "0.26.4" rustls-pemfile = "2.2.0" +x509-cert = "0.2" tonic = { version = "0.12", features = ["tls"] } axum = "0.7" axum-server = { version = "0.7", features = ["tls-rustls"] } diff --git a/src/config.rs b/src/config.rs index a612a23..0812560 100644 --- a/src/config.rs +++ b/src/config.rs @@ -400,6 +400,13 @@ pub struct Uplink { pub host: String, pub port: u16, pub password: String, + // Connect to the uplink over TLS, authenticated by pinning the server's SPKI + // fingerprint (base64 SHA256 of its SubjectPublicKeyInfo) rather than a CA — the + // link cert is typically self-signed. Off by default (plaintext, e.g. loopback). + #[serde(default)] + pub tls: bool, + #[serde(default)] + pub spki_fingerprint: String, } #[derive(Debug, Deserialize)] diff --git a/src/link.rs b/src/link.rs index 0cb14f2..d8ff34c 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1,15 +1,57 @@ use std::borrow::Cow; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use anyhow::Result; -use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf}; use tokio::net::TcpStream; use tokio::sync::{mpsc, Mutex}; +use tokio_rustls::client::TlsStream; +use tokio_rustls::rustls::pki_types::ServerName; +use tokio_rustls::TlsConnector; use crate::engine::db::Db; use crate::engine::Engine; use crate::proto::{NetAction, Protocol}; +// The uplink socket, plaintext or TLS. Both inner types are Unpin, so projecting +// the pin is a plain re-pin of the inner stream. +enum UplinkStream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for UplinkStream { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + match self.get_mut() { + UplinkStream::Plain(s) => Pin::new(s).poll_read(cx, buf), + UplinkStream::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for UplinkStream { + fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + match self.get_mut() { + UplinkStream::Plain(s) => Pin::new(s).poll_write(cx, buf), + UplinkStream::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + UplinkStream::Plain(s) => Pin::new(s).poll_flush(cx), + UplinkStream::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + UplinkStream::Plain(s) => Pin::new(s).poll_shutdown(cx), + UplinkStream::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx), + } + } +} + // Cap on one uplink line (well above IRC's 512 + generous room for message tags), // so a misbehaving uplink can't grow an unbounded read buffer. const MAX_UPLINK_LINE: usize = 16 * 1024; @@ -100,13 +142,19 @@ fn redact(line: &str) -> Cow<'_, str> { // The engine is shared with the gossip layer, so it is locked per operation and // never held across the registration key-stretching await. #[allow(clippy::too_many_arguments)] // the link driver legitimately wires up many collaborators -pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, irc_tx: mpsc::UnboundedSender, email: Option, keycard: Option, dict_server: Option) -> Result<()> { - let stream = TcpStream::connect(addr).await?; +pub async fn run(mut proto: Box, engine: Arc>, addr: &str, tls: Option<(TlsConnector, ServerName<'static>)>, mut irc_rx: mpsc::UnboundedReceiver, irc_tx: mpsc::UnboundedSender, email: Option, keycard: Option, dict_server: Option) -> Result<()> { + let tcp = TcpStream::connect(addr).await?; // Disable Nagle: service replies are small multi-line bursts, and without this // the last segment of a reply is held ~40ms waiting on a delayed ACK, so a // HELP visibly lags before it lands. Every ircd sets this on every socket. - stream.set_nodelay(true)?; - let (read, mut write) = stream.into_split(); + tcp.set_nodelay(true)?; + // Wrap in TLS (SPKI-pinned) when configured; otherwise stay plaintext. + let stream = match tls { + Some((connector, name)) => UplinkStream::Tls(Box::new(connector.connect(name, tcp).await?)), + None => UplinkStream::Plain(tcp), + }; + let (read, write) = tokio::io::split(stream); + let mut write = write; let mut reader = BufReader::new(read); for line in proto.handshake() { diff --git a/src/main.rs b/src/main.rs index 9a6d2fd..1947fde 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod health; mod jsonrpc; mod keycard; mod link; +mod uplink_tls; mod migrate; mod proto; mod version; @@ -377,13 +378,20 @@ async fn main() -> Result<()> { } let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); - tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); + let uplink_tls = match uplink_tls::connector(&cfg.uplink) { + Ok(t) => t, + Err(e) => { + tracing::error!(%e, "uplink TLS misconfigured"); + return Err(e); + } + }; + tracing::info!(server = %cfg.server.name, %addr, tls = uplink_tls.is_some(), "linking to uplink"); // Run until the uplink loop ends or the process is asked to stop. Every // committed change is already fsync'd, so a clean stop loses nothing; this // just lets systemd stop us without waiting out the kill timeout. let shutdown_engine = engine.clone(); tokio::select! { - res = link::run(proto, engine, &addr, irc_rx, irc_tx, cfg.email.clone(), cfg.keycard.clone(), cfg.dictserv.as_ref().map(|d| d.server.clone())) => res, + res = link::run(proto, engine, &addr, uplink_tls, irc_rx, irc_tx, cfg.email.clone(), cfg.keycard.clone(), cfg.dictserv.as_ref().map(|d| d.server.clone())) => res, _ = shutdown_signal() => { // Flush stat counters + the incident ring so a clean stop/restart keeps // StatServ history and OperServ LOGSEARCH. diff --git a/src/uplink_tls.rs b/src/uplink_tls.rs new file mode 100644 index 0000000..ffa5f71 --- /dev/null +++ b/src/uplink_tls.rs @@ -0,0 +1,94 @@ +//! Optional TLS for the ircd uplink (#370). The link cert is typically self-signed, +//! so instead of a CA we authenticate the server by pinning its SPKIFP — the base64 +//! SHA256 of its SubjectPublicKeyInfo (which, unlike a whole-cert fingerprint, +//! survives a certificate renewal that keeps the same key). The normal PASS +//! handshake still runs inside the TLS channel. + +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use base64::Engine; +use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use tokio_rustls::rustls::crypto::{self, CryptoProvider}; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme}; +use tokio_rustls::TlsConnector; + +use crate::config::Uplink; + +/// The SPKIFP of a certificate: base64(SHA256(SubjectPublicKeyInfo DER)). +pub fn spki_fingerprint(cert: &CertificateDer) -> Result { + use x509_cert::der::{Decode, Encode}; + let parsed = x509_cert::Certificate::from_der(cert.as_ref())?; + let spki = parsed.tbs_certificate.subject_public_key_info.to_der()?; + let digest = ::digest(&spki); + Ok(base64::engine::general_purpose::STANDARD.encode(digest)) +} + +// Accept the server iff its SPKIFP matches the pinned value; the handshake +// signature is still verified against that key by the crypto provider, so only the +// holder of the pinned key can complete the connection. +#[derive(Debug)] +struct SpkiPin { + expected: String, + provider: Arc, +} + +impl ServerCertVerifier for SpkiPin { + fn verify_server_cert(&self, end_entity: &CertificateDer, _intermediates: &[CertificateDer], _server_name: &ServerName, _ocsp: &[u8], _now: UnixTime) -> Result { + let got = spki_fingerprint(end_entity).map_err(|e| Error::General(format!("uplink cert unreadable: {e}")))?; + if got == self.expected { + Ok(ServerCertVerified::assertion()) + } else { + Err(Error::General("uplink SPKI fingerprint does not match the pinned value".into())) + } + } + + fn verify_tls12_signature(&self, message: &[u8], cert: &CertificateDer, dss: &DigitallySignedStruct) -> Result { + crypto::verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn verify_tls13_signature(&self, message: &[u8], cert: &CertificateDer, dss: &DigitallySignedStruct) -> Result { + crypto::verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider.signature_verification_algorithms.supported_schemes() + } +} + +/// A TLS connector + server name for the uplink, or `None` when TLS is off. +pub fn connector(uplink: &Uplink) -> Result)>> { + if !uplink.tls { + return Ok(None); + } + if uplink.spki_fingerprint.is_empty() { + return Err(anyhow!("uplink.tls is set but uplink.spki_fingerprint is empty")); + } + // Install a process-default provider if nothing else did yet (idempotent). + let _ = crypto::aws_lc_rs::default_provider().install_default(); + let provider = Arc::new(crypto::aws_lc_rs::default_provider()); + let verifier = Arc::new(SpkiPin { expected: uplink.spki_fingerprint.clone(), provider }); + let config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + let name = ServerName::try_from(uplink.host.clone())?; + Ok(Some((TlsConnector::from(Arc::new(config)), name))) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The SPKIFP matches `openssl x509 -pubkey | openssl pkey -pubin -outform DER | + // openssl dgst -sha256 -binary | base64` for this fixed self-signed cert. + #[test] + fn spki_fingerprint_matches_openssl() { + let der = base64::engine::general_purpose::STANDARD.decode( + "MIIDCTCCAfGgAwIBAgIUJf3uwnHdwpCANK0wddShifEEProwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJZWNoby10ZXN0MB4XDTI2MDcyMTEzNDIxNFoXDTI2MDcyMzEzNDIxNFowFDESMBAGA1UEAwwJZWNoby10ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/L8m2F/7ELMezgktDchrlVGh6VQpT+uatXG0IvVPOS9BNm60xLAaijEvv7oI1paWCBM4EKpRPhZH5Of5xyBNtvfpyUdLKKeS5NiRNrGQJwXU0wz8j1Wrwe6qyffWPBO3RlBd+PvRLgCGlE1oV5gyw+TkjCo+9eLNgwIVIJP5/HCmEohSgtpOjqOthzVt/DjwfaH8VUbHC9r718cNfj1stfL46wW/sevQThmqEwFshOWxGKPbcJNhlpcC/LXr6YO2RwlzGnF2AEkvC7ZzG4dKCDZNYYgOgR7sKFcUArBE+4mvKQBcG1qDj4GBLeH82n0jRZSf0f6EpHjKCPTkvnJswIDAQABo1MwUTAdBgNVHQ4EFgQU6cgHWvusLXkexbuZ/9KOtzexegMwHwYDVR0jBBgwFoAU6cgHWvusLXkexbuZ/9KOtzexegMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAbuujq+xvRbRlq+FXC2PHhWlyCqHPEn7rKC7vr77rdXIMVxAzY3rlAfHgyTgL41XrDsx58dfKswUp3g2VxJZHwKXQhoCKRWo+fAxJ6bHVWXrPkJJYZtI/JtggKNcORWw6VT0081UhXaRANDlGHMQ+MfTr3NaNz+aIAMhMtL4BSobSocjn4eAUaFxXVF0yznT8+U8MiGMgThOLrnyvzg9NK7D4iNf3gC1XRyD1RHP6w7ji/tpmLR4CQ6A3i2ywYD8ZQilBQhBa2abzP7zxHbQe8wHbopWPaz5QQ34bxpZ8MLKgY2OqzNndqt8+ZUYv9uk8AllnBT/fQbWC1b02B8gNMQ==", + ).unwrap(); + let cert = CertificateDer::from(der); + assert_eq!(spki_fingerprint(&cert).unwrap(), "Wh/ag/dtBCpXhiYPhRWV66kx3FutmTyNw1dysTbWvao="); + } +}