diff --git a/CLAUDE.md b/CLAUDE.md index 6d80ea6..bff9acb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Do not assume a feature works because a type for it exists. As of this writing: - **AirPlay sending** is partly working. HAP pairing used a fabricated SRP group and could never succeed; that is fixed and now uses the real RFC 5054 3072-bit group. Whether the handshake works end to end is still unconfirmed against hardware (issue #27). FairPlay **will not be implemented** — `fp_setup` has no callers and must not acquire any, and Apple TV 2nd/3rd generation are refused by model string by design. See the decision in `docs/crypto.md`. - **OpenPlay (WebRTC)** is **not wired to either binary**. `SenderPipeline`, `ReceiverPipeline`, `SignalingServer`, `SignalingClient` and `ReceiverAdvertiser` are implemented and have no callers. The sender's `Protocol::OpenPlay` arm sets a status string and stops; the receiver window is static. - **Screen capture is only exercised on Linux.** On macOS and Windows `CaptureSession` just reports the display size and capture is left to GStreamer's own elements; that path is untested. The Windows build was broken outright until #25 made `openplay-capture` declare the `windows` crate it uses. -- **`CertificateManager`** is never constructed outside its own tests, and `openplay-crypto` no longer depends on `rustls`. +- **`CertificateManager`** is never constructed outside its own tests. `openplay-crypto` depends on `rustls` again: #34 added the signaling channel's TLS config builders in `tls.rs`, and they have no callers either. - **Unused dependencies were removed** from every crate. If you wire up a path that needs `openplay-signaling`, `-protocol` or `-crypto`, re-add the declaration. ## Architecture diff --git a/Cargo.lock b/Cargo.lock index 2b2d92f..a6e7fa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3093,9 +3093,12 @@ version = "0.1.0" dependencies = [ "anyhow", "rcgen", + "rustls", "rustls-pemfile", "sha2", "tempfile", + "tokio", + "tokio-rustls", "tracing", ] diff --git a/crates/openplay-crypto/Cargo.toml b/crates/openplay-crypto/Cargo.toml index fb9dc91..535c89e 100644 --- a/crates/openplay-crypto/Cargo.toml +++ b/crates/openplay-crypto/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "TLS/DTLS certificate management and pairing for OpenPlay" [dependencies] +rustls = { workspace = true } rustls-pemfile = { workspace = true } rcgen = { workspace = true } sha2 = { workspace = true } @@ -16,3 +17,5 @@ tracing = { workspace = true } [dev-dependencies] tempfile = "3" +tokio = { workspace = true } +tokio-rustls = "0.26" diff --git a/crates/openplay-crypto/src/lib.rs b/crates/openplay-crypto/src/lib.rs index cf5d7e4..9ce1b3d 100644 --- a/crates/openplay-crypto/src/lib.rs +++ b/crates/openplay-crypto/src/lib.rs @@ -8,8 +8,10 @@ //! See `docs/crypto.md`. mod certs; +mod tls; pub use certs::CertificateManager; +pub use tls::client_config_pinned; use sha2::{Digest, Sha256}; diff --git a/crates/openplay-crypto/src/tls.rs b/crates/openplay-crypto/src/tls.rs new file mode 100644 index 0000000..624f763 --- /dev/null +++ b/crates/openplay-crypto/src/tls.rs @@ -0,0 +1,229 @@ +//! rustls configuration for the OpenPlay signaling channel. +//! +//! The receiver's certificate is self-signed and its address is a bare LAN IP, +//! so neither end can use webpki's usual path: there is no CA to chain to and +//! no hostname to match. Instead the sender pins the SHA-256 fingerprint of the +//! receiver's certificate, which mDNS already advertises in the `fp` TXT key. +//! +//! # What pinning does and does not buy +//! +//! The TXT record is unauthenticated, so an attacker on the same LAN can +//! advertise a receiver with their own fingerprint and a sender that has never +//! seen the real one will pin the attacker's certificate. Pinning therefore +//! gives confidentiality against a passive eavesdropper, and detects a +//! substituted certificate on any *later* connection, but it is not by itself +//! authentication of the receiver. That requires the user to confirm a code +//! shown on both screens — which is what the `PairingChallenge` / +//! `PairingConfirm` messages in `openplay-protocol` are for. They are not +//! wired up yet. + +use anyhow::{anyhow, Context, Result}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{CryptoProvider, WebPkiSupportedAlgorithms}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ + ClientConfig, DigitallySignedStruct, Error as TlsError, ServerConfig, SignatureScheme, +}; +use std::sync::Arc; + +use crate::{certificate_fingerprint, CertificateManager}; + +impl CertificateManager { + /// Builds a rustls [`ServerConfig`] presenting this manager's certificate. + /// + /// Client certificates are not requested: the sender is authenticated by the + /// pairing exchange, not by TLS. + pub fn server_config(&self) -> Result> { + let certs = rustls_pemfile::certs(&mut self.cert_pem().as_bytes()) + .collect::, _>>() + .context("Failed to parse certificate PEM")?; + if certs.is_empty() { + return Err(anyhow!("Certificate PEM contained no certificates")); + } + + let key = rustls_pemfile::private_key(&mut self.key_pem().as_bytes()) + .context("Failed to parse private key PEM")? + .ok_or_else(|| anyhow!("Private key PEM contained no key"))?; + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .context("Failed to build rustls ServerConfig")?; + + Ok(Arc::new(config)) + } +} + +/// Builds a rustls [`ClientConfig`] that accepts exactly one certificate: the +/// one whose SHA-256 fingerprint matches `expected_fingerprint`. +/// +/// `expected_fingerprint` is the value from the receiver's mDNS `fp` TXT key. +/// Comparison ignores case and separators, so `A3:B2:...`, `a3b2...` and +/// `a3-b2-...` are equivalent. +/// +/// See the module documentation for what this does not protect against. +pub fn client_config_pinned(expected_fingerprint: &str) -> Result> { + let normalised = normalise_fingerprint(expected_fingerprint); + if normalised.len() != 64 { + return Err(anyhow!( + "Expected a 64-hex-digit SHA-256 fingerprint, got {} digits", + normalised.len() + )); + } + + let provider = CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider())); + + let verifier = PinnedCertVerifier { + expected: normalised, + algorithms: provider.signature_verification_algorithms, + }; + + let config = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .context("Failed to select TLS protocol versions")? + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + Ok(Arc::new(config)) +} + +/// Lowercases and strips separators so fingerprints compare by value. +fn normalise_fingerprint(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_hexdigit()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +/// A [`ServerCertVerifier`] that accepts one specific certificate by fingerprint. +/// +/// Signature verification is still delegated to the crypto provider — pinning +/// replaces *identity* checking only. A pinned certificate that cannot sign the +/// handshake is still rejected. +#[derive(Debug)] +struct PinnedCertVerifier { + expected: String, + algorithms: WebPkiSupportedAlgorithms, +} + +impl ServerCertVerifier for PinnedCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let presented = normalise_fingerprint(&certificate_fingerprint(end_entity.as_ref())); + + if presented == self.expected { + Ok(ServerCertVerified::assertion()) + } else { + Err(TlsError::General(format!( + "certificate fingerprint mismatch: receiver presented {presented}, \ + mDNS advertised {}", + self.expected + ))) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.algorithms.supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives the verifier directly, which is the decision pinning actually makes. + fn verify_against(pinned: &str, cert_der: &[u8]) -> Result { + let provider = CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider())); + let verifier = PinnedCertVerifier { + expected: normalise_fingerprint(pinned), + algorithms: provider.signature_verification_algorithms, + }; + verifier.verify_server_cert( + &CertificateDer::from(cert_der.to_vec()), + &[], + &ServerName::try_from("192.168.1.10").unwrap(), + &[], + UnixTime::now(), + ) + } + + #[test] + fn server_config_builds_from_a_generated_certificate() { + let mgr = CertificateManager::generate().unwrap(); + assert!(mgr.server_config().is_ok()); + } + + #[test] + fn verifier_accepts_the_pinned_certificate() { + let mgr = CertificateManager::generate().unwrap(); + assert!(verify_against(mgr.fingerprint(), mgr.cert_der()).is_ok()); + } + + #[test] + fn verifier_rejects_a_different_certificate() { + let receiver = CertificateManager::generate().unwrap(); + let impostor = CertificateManager::generate().unwrap(); + assert_ne!(receiver.fingerprint(), impostor.fingerprint()); + + // Sender pinned the real receiver; an impostor answers instead. + let err = verify_against(receiver.fingerprint(), impostor.cert_der()).unwrap_err(); + assert!( + err.to_string().contains("fingerprint mismatch"), + "expected a mismatch error, got {err}" + ); + } + + #[test] + fn fingerprint_comparison_ignores_case_and_separators() { + let mgr = CertificateManager::generate().unwrap(); + let colonned = mgr.fingerprint().to_string(); + let bare = colonned.replace(':', ""); + + assert!(verify_against(&colonned, mgr.cert_der()).is_ok()); + assert!(verify_against(&bare, mgr.cert_der()).is_ok()); + assert!(verify_against(&bare.to_lowercase(), mgr.cert_der()).is_ok()); + } + + #[test] + fn client_config_rejects_a_malformed_fingerprint() { + assert!(client_config_pinned("not-a-fingerprint").is_err()); + assert!(client_config_pinned("").is_err()); + // 63 digits: one short of SHA-256. + assert!(client_config_pinned(&"a".repeat(63)).is_err()); + } + + #[test] + fn client_config_accepts_the_advertised_form() { + let mgr = CertificateManager::generate().unwrap(); + assert!(client_config_pinned(mgr.fingerprint()).is_ok()); + } +} diff --git a/crates/openplay-crypto/tests/tls_handshake_test.rs b/crates/openplay-crypto/tests/tls_handshake_test.rs new file mode 100644 index 0000000..782d9e5 --- /dev/null +++ b/crates/openplay-crypto/tests/tls_handshake_test.rs @@ -0,0 +1,89 @@ +//! End-to-end proof that `server_config()` and `client_config_pinned()` +//! interoperate: a real TLS handshake over a loopback socket. + +use openplay_crypto::{client_config_pinned, CertificateManager}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +/// Serves exactly one TLS connection, echoing a byte. Returns the bound port. +async fn spawn_server(mgr: &CertificateManager) -> u16 { + let acceptor = TlsAcceptor::from(mgr.server_config().unwrap()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + if let Ok((tcp, _)) = listener.accept().await { + if let Ok(mut tls) = acceptor.accept(tcp).await { + let _ = tls.write_all(b"k").await; + let _ = tls.flush().await; + } + } + }); + + port +} + +async fn try_connect(port: u16, pinned: &str) -> anyhow::Result { + let connector = TlsConnector::from(client_config_pinned(pinned)?); + let tcp = TcpStream::connect(("127.0.0.1", port)).await?; + let server_name = rustls::pki_types::ServerName::try_from("127.0.0.1")?; + let mut tls = connector.connect(server_name, tcp).await?; + + let mut buf = [0u8; 1]; + tls.read_exact(&mut buf).await?; + Ok(buf[0]) +} + +#[tokio::test] +async fn handshake_succeeds_when_the_fingerprint_matches() { + let mgr = CertificateManager::generate().unwrap(); + let port = spawn_server(&mgr).await; + + let byte = try_connect(port, mgr.fingerprint()) + .await + .expect("handshake should succeed against the pinned certificate"); + assert_eq!(byte, b'k'); +} + +#[tokio::test] +async fn handshake_fails_when_a_different_certificate_is_presented() { + let receiver = CertificateManager::generate().unwrap(); + let impostor = CertificateManager::generate().unwrap(); + let port = spawn_server(&impostor).await; + + // Sender pinned the real receiver's fingerprint from mDNS; an impostor answers. + let err = try_connect(port, receiver.fingerprint()) + .await + .expect_err("handshake must fail when the presented cert is not the pinned one"); + + let msg = err.to_string(); + assert!( + msg.contains("fingerprint mismatch") || msg.contains("certificate"), + "expected a certificate rejection, got: {msg}" + ); +} + +/// A self-signed cert has no CA and the address is a bare IP, so a stock +/// verifier would reject it. This is what makes pinning necessary rather than +/// merely convenient. +#[tokio::test] +async fn the_default_verifier_would_reject_the_same_certificate() { + let mgr = CertificateManager::generate().unwrap(); + let port = spawn_server(&mgr).await; + + let roots = rustls::RootCertStore::empty(); + let config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + let tcp = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let name = rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap(); + + assert!( + connector.connect(name, tcp).await.is_err(), + "an empty trust store must reject this self-signed certificate" + ); +} diff --git a/docs/crypto.md b/docs/crypto.md index 40c49a4..f2f1cd9 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -15,6 +15,7 @@ Original report: issue #8 (closed). Hardware confirmation is tracked in issue #2 | HAP pair-verify | `airplay/hap_pairing.rs` | Implemented, unreachable until pairing is confirmed | | FairPlay | `airplay/fairplay.rs` | **Will not be implemented** (decision below), and not wired in — `fp_setup` has no callers | | TLS certificates | `openplay-crypto/certs.rs` | Implemented, never constructed anywhere | +| Signaling TLS config | `openplay-crypto/tls.rs` | Implemented, no callers — fingerprint pinning, which is **not** peer authentication | ## HAP pair-setup — fixed