Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/openplay-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -16,3 +17,5 @@ tracing = { workspace = true }

[dev-dependencies]
tempfile = "3"
tokio = { workspace = true }
tokio-rustls = "0.26"
2 changes: 2 additions & 0 deletions crates/openplay-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
229 changes: 229 additions & 0 deletions crates/openplay-crypto/src/tls.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<ServerConfig>> {
let certs = rustls_pemfile::certs(&mut self.cert_pem().as_bytes())
.collect::<Result<Vec<_>, _>>()
.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<Arc<ClientConfig>> {
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<ServerCertVerified, TlsError> {
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<HandshakeSignatureValid, TlsError> {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms)
}

fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<ServerCertVerified, TlsError> {
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());
}
}
89 changes: 89 additions & 0 deletions crates/openplay-crypto/tests/tls_handshake_test.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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"
);
}
1 change: 1 addition & 0 deletions docs/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading