diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index ac40f000..6b24b9b5 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -125,7 +125,7 @@ pub fn agent( .with_safe_default_protocol_versions() .map_err(|e| bad("tls config", &e))? .dangerous() - .with_custom_certificate_verifier(Arc::new(PinVerify { pin })); + .with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin))); let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes()) .map_err(|e| bad("client cert pem", &e))?; let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes()) @@ -251,71 +251,6 @@ fn classify(e: ureq::Error) -> LibraryError { } } -/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC -/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake -/// signatures MUST still be verified for real: CertificateVerify is what proves the peer -/// *holds the pinned cert's private key* — skip it and an active MITM can replay the -/// host's (public) certificate, match the pin, and complete the handshake with its own key. -#[derive(Debug)] -struct PinVerify { - pin: Option<[u8; 32]>, -} - -impl rustls::client::danger::ServerCertVerifier for PinVerify { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - if let Some(expected) = self.pin { - let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref()); - if fp != expected { - return Err(rustls::Error::InvalidCertificate( - rustls::CertificateError::ApplicationVerificationFailure, - )); - } - } - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index 91542792..8f9d8047 100644 --- a/crates/punktfunk-core/Cargo.toml +++ b/crates/punktfunk-core/Cargo.toml @@ -17,9 +17,13 @@ crate-type = ["lib", "cdylib", "staticlib"] [features] default = [] +# Cert-fingerprint pinning shared across the clients (the one `PinVerify` in `tls.rs`). Light: +# rustls + sha2 only, no QUIC runtime — so a lean consumer (the tray's loopback status poll) can +# pin the host cert without pulling tokio/quinn. The heavier `quic` feature builds on top of it. +tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"] # Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here, # never on the per-frame hot path. Off by default so the core stays runtime-free. -quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"] +quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"] [dependencies] reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2) diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 7ef09e75..f3aabfd5 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -42,6 +42,8 @@ pub mod reanchor; pub mod reject; pub mod session; pub mod stats; +#[cfg(feature = "tls")] +pub mod tls; pub mod transport; pub mod wol; diff --git a/crates/punktfunk-core/src/quic/endpoint.rs b/crates/punktfunk-core/src/quic/endpoint.rs index b99ca4c8..f5606a72 100644 --- a/crates/punktfunk-core/src/quic/endpoint.rs +++ b/crates/punktfunk-core/src/quic/endpoint.rs @@ -1,5 +1,6 @@ //! Shared QUIC endpoint construction (host + client) and transport tuning — keep-alive and idle -//! timeout, certificate-fingerprint helpers, and the TOFU cert-pinning verifier (`PinVerify`). +//! timeout, plus the certificate-fingerprint helpers. The cert-pinning verifier itself is the +//! one shared [`crate::tls::PinVerify`]; this module only wires it into the client endpoint. use std::sync::{Arc, Mutex}; /// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the @@ -132,11 +133,10 @@ pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> { certs.first().map(|c| cert_fingerprint(c.as_ref())) } -/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. -pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(cert_der).into() -} +/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. The implementation is +/// [`crate::tls::cert_fingerprint`] (shared with the HTTP clients); re-exported here so callers +/// reaching it as `quic::endpoint::cert_fingerprint` keep working. +pub use crate::tls::cert_fingerprint; /// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX — /// must match what the client's verifier computes from the DER on the wire). @@ -180,10 +180,10 @@ pub fn client_pinned_with_identity( let _ = rustls::crypto::ring::default_provider().install_default(); let builder = rustls::ClientConfig::builder() .dangerous() - .with_custom_certificate_verifier(Arc::new(PinVerify { + .with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed( pin, - observed: observed.clone(), - })); + observed.clone(), + ))); let mut rustls_cfg = match identity { None => builder.with_no_client_auth(), Some((cert_pem, key_pem)) => { @@ -234,9 +234,6 @@ pub mod anyhow_result { } } -/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf -/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what -/// it saw, so the embedder can persist the fingerprint and pin it from then on. /// Server-side client-cert verifier: accept any (self-signed) client certificate but /// verify the handshake signature for real — possession of the presented cert's key is /// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful. @@ -296,69 +293,3 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert { .supported_schemes() } } - -#[derive(Debug)] -struct PinVerify { - pin: Option<[u8; 32]>, - observed: Arc>>, -} - -impl rustls::client::danger::ServerCertVerifier for PinVerify { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> std::result::Result { - let fp = cert_fingerprint(end_entity.as_ref()); - *self.observed.lock().unwrap() = Some(fp); - if let Some(expected) = self.pin { - if fp != expected { - return Err(rustls::Error::InvalidCertificate( - rustls::CertificateError::ApplicationVerificationFailure, - )); - } - } - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - // The handshake signatures MUST be verified for real even though we pin the cert: - // CertificateVerify is what proves the peer *holds the pinned cert's private key* — - // skip it and an active MITM can replay the host's (public) certificate, match the - // pin, and complete the handshake with its own key. - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> std::result::Result { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> std::result::Result { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } -} diff --git a/crates/punktfunk-core/src/tls.rs b/crates/punktfunk-core/src/tls.rs new file mode 100644 index 00000000..d3d29ac5 --- /dev/null +++ b/crates/punktfunk-core/src/tls.rs @@ -0,0 +1,164 @@ +//! Shared TLS trust primitives for the punktfunk clients: the certificate-fingerprint hash and +//! the one canonical fingerprint-pinning [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier) +//! (`PinVerify`). Trust across the whole system is the SHA-256 of the host's self-signed leaf cert +//! (TOFU-pinned), not a CA chain — and this verifier is what the QUIC connect, the game-library +//! HTTP client, and the tray status poll all share, instead of hand-rolling it three times on a +//! trust boundary. Behind the light `tls` feature (rustls + sha2 only — no QUIC runtime), which +//! the heavier `quic` feature pulls in. + +use std::sync::{Arc, Mutex}; + +/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as +/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there. +pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(cert_der).into() +} + +/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf cert, +/// not a CA chain. +/// +/// - `pin = Some(sha256)` rejects any host whose leaf doesn't hash to `sha256`. +/// - `pin = None` accepts any leaf (trust-on-first-use) — pair with [`with_observed`](Self::with_observed) +/// to record what was seen so the embedder can persist it and pin it from then on. +/// +/// The handshake signatures are ALWAYS verified for real even though the cert is pinned: +/// `CertificateVerify` is what proves the peer *holds the pinned cert's private key* — skip it and +/// an active MITM can replay the host's (public) certificate, match the pin, and complete the +/// handshake with its own key. +#[derive(Debug)] +pub struct PinVerify { + pin: Option<[u8; 32]>, + observed: Option>>>, +} + +impl PinVerify { + /// A verifier that pins `pin` (or accepts any when `None`) without recording what it saw — + /// the HTTP clients, which connect with a known pin or accept-any and never need to persist + /// the observed fingerprint. + pub fn new(pin: Option<[u8; 32]>) -> Self { + Self { + pin, + observed: None, + } + } + + /// Like [`new`](Self::new) but also writes the observed leaf fingerprint into `slot` during + /// the handshake, so a trust-on-first-use caller (the QUIC connect) can read it off the slot + /// and pin it on the next connect. + pub fn with_observed(pin: Option<[u8; 32]>, slot: Arc>>) -> Self { + Self { + pin, + observed: Some(slot), + } + } +} + +impl rustls::client::danger::ServerCertVerifier for PinVerify { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result { + // Only hash the leaf when the result depends on it: a pin to check and/or a slot to + // record into. Accept-any-without-recording (an un-pinned HTTP agent) skips it. + if self.pin.is_some() || self.observed.is_some() { + let fp = cert_fingerprint(end_entity.as_ref()); + if let Some(slot) = &self.observed { + *slot.lock().unwrap() = Some(fp); + } + if let Some(expected) = self.pin { + if fp != expected { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + } + } + } + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustls::client::danger::ServerCertVerifier; + use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; + + /// Drive the pin check against `cert_bytes`. `verify_server_cert` only hashes the leaf, so + /// arbitrary bytes stand in for a DER certificate here. + fn verify(v: &PinVerify, cert_bytes: &[u8]) -> std::result::Result<(), rustls::Error> { + let der = CertificateDer::from(cert_bytes.to_vec()); + let name = ServerName::try_from("punktfunk").unwrap(); + v.verify_server_cert( + &der, + &[], + &name, + &[], + UnixTime::since_unix_epoch(std::time::Duration::ZERO), + ) + .map(|_| ()) + } + + #[test] + fn matching_pin_accepts_and_mismatch_is_rejected() { + let cert = b"the-host-leaf-cert"; + let good = cert_fingerprint(cert); + assert!(verify(&PinVerify::new(Some(good)), cert).is_ok()); + + let mut wrong = good; + wrong[0] ^= 0xff; + match verify(&PinVerify::new(Some(wrong)), cert) { + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) => {} + other => panic!("a pin mismatch must be rejected, got {other:?}"), + } + } + + #[test] + fn no_pin_accepts_any_and_records_the_observed_fingerprint() { + let cert = b"whatever-the-host-presents"; + let slot = Arc::new(Mutex::new(None)); + let v = PinVerify::with_observed(None, slot.clone()); + assert!(verify(&v, cert).is_ok()); + assert_eq!(*slot.lock().unwrap(), Some(cert_fingerprint(cert))); + } +} diff --git a/crates/punktfunk-tray/Cargo.toml b/crates/punktfunk-tray/Cargo.toml index 758b903c..f51ac5b4 100644 --- a/crates/punktfunk-tray/Cargo.toml +++ b/crates/punktfunk-tray/Cargo.toml @@ -28,7 +28,11 @@ serde_json = "1" # pins the ring provider explicitly anyway). ureq = { version = "2", default-features = false, features = ["tls"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } -sha2 = "0.10" +# The one shared cert-fingerprint pin verifier (`punktfunk_core::tls::PinVerify`) + fingerprint +# hash, instead of the tray hand-rolling its own copy on a trust boundary. The light `tls` feature +# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper; core is a pure-Rust +# leaf (no C toolchain), unlike the host dependency ruled out above. +punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls"] } [target.'cfg(windows)'.dependencies] # SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses. diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs index f69b9914..c328baf3 100644 --- a/crates/punktfunk-tray/src/status.rs +++ b/crates/punktfunk-tray/src/status.rs @@ -257,10 +257,9 @@ fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option { /// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box. fn load_pin() -> Option<[u8; 32]> { use rustls::pki_types::pem::PemObject; - use sha2::Digest; let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?; let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?; - Some(sha2::Sha256::digest(der.as_ref()).into()) + Some(punktfunk_core::tls::cert_fingerprint(der.as_ref())) } /// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate: @@ -297,7 +296,7 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent { .with_safe_default_protocol_versions() .expect("rustls default protocol versions") .dangerous() - .with_custom_certificate_verifier(Arc::new(PinVerify { pin })) + .with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin))) .with_no_client_auth(); ureq::AgentBuilder::new() .tls_config(Arc::new(cfg)) @@ -306,69 +305,6 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent { .build() } -/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake -/// signatures are still verified for real — CertificateVerify proves the peer holds the key. -#[derive(Debug)] -struct PinVerify { - pin: Option<[u8; 32]>, -} - -impl rustls::client::danger::ServerCertVerifier for PinVerify { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - use sha2::Digest; - if let Some(expected) = self.pin { - let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into(); - if fp != expected { - return Err(rustls::Error::InvalidCertificate( - rustls::CertificateError::ApplicationVerificationFailure, - )); - } - } - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } -} - // ── Service-manager probe ─────────────────────────────────────────────────────────────────────── /// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).