From 50925da6c1e1332c6ffc0f4c71e15584fd05fc75 Mon Sep 17 00:00:00 2001 From: Sandeepa Nadahalli <1698507+snadahalli@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:54:27 +0530 Subject: [PATCH 1/2] feat(crypto): add rustls config builders for the signaling channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Adds `CertificateManager::server_config()` and `client_config_pinned()`, plus a `PinnedCertVerifier` that replaces identity checking only — signature verification still goes through the crypto provider, so a pinned certificate that cannot sign the handshake is still rejected. Pinning is deliberately not authentication. The TXT record is unauthenticated, so an attacker on the same LAN can advertise their own fingerprint and a sender that has never seen the real one will pin it. This buys confidentiality against a passive eavesdropper and detects a substituted certificate on any later connection; authenticating the receiver needs the `PairingChallenge`/`PairingConfirm` exchange in openplay-protocol, which is not wired up yet. The module docs say so. Tests drive the verifier directly and also run a real TLS handshake over a loopback socket, including a case asserting that a stock verifier with an empty trust store rejects the same certificate — which is what makes pinning necessary rather than merely convenient. Nothing constructs this yet; `openplay-signaling` is still plaintext. --- Cargo.lock | 3 + crates/openplay-crypto/Cargo.toml | 3 + crates/openplay-crypto/src/lib.rs | 2 + crates/openplay-crypto/src/tls.rs | 229 ++++++++++++++++++ .../tests/tls_handshake_test.rs | 89 +++++++ 5 files changed, 326 insertions(+) create mode 100644 crates/openplay-crypto/src/tls.rs create mode 100644 crates/openplay-crypto/tests/tls_handshake_test.rs 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" + ); +} From 57759c090aab91b07079abc6842868d7ba5b944d Mon Sep 17 00:00:00 2001 From: Sandeepa Nadahalli <1698507+snadahalli@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:53:24 +0530 Subject: [PATCH 2/2] docs: record that openplay-crypto depends on rustls again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md's project-status section stated that `openplay-crypto` no longer depends on `rustls`, which this PR makes false. The neighbouring claim in the same line — that `CertificateManager` is never constructed outside its own tests — stays true, since the config builders added here have no callers. Also gives `tls.rs` a row in the crypto status table, noting that pinning is not peer authentication so the table cannot be read as claiming the signaling channel is authenticated. --- CLAUDE.md | 2 +- docs/crypto.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/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