diff --git a/crates/openplay-airplay/examples/control_probe.rs b/crates/openplay-airplay/examples/control_probe.rs new file mode 100644 index 0000000..0fbd4b6 --- /dev/null +++ b/crates/openplay-airplay/examples/control_probe.rs @@ -0,0 +1,79 @@ +//! Probes what a receiver exposes *after* a transient pair-setup. +//! +//! `pair_probe` stops once SRP completes. This goes one step further: it opens +//! the encrypted control channel with the negotiated session key and asks the +//! receiver which endpoints it actually serves. Both halves are things no unit +//! test can answer, because both depend on the receiver's behaviour. +//! +//! ```console +//! cargo run -p openplay-airplay --example control_probe -- 192.168.1.11:7000 +//! ``` + +use openplay_airplay::{control_channel::ControlChannel, hap_pairing, http_session}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + + let addr: std::net::SocketAddr = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: control_probe :"))? + .parse()?; + + println!("── transient pair-setup against {addr}"); + let session = hap_pairing::pair_setup_transient(addr).await?; + println!( + " ✅ M4 verified, session key {} bytes", + session.session_key.len() + ); + + println!("\n── encrypted control channel"); + let mut chan = ControlChannel::new(session.stream, &session.session_key)?; + let info = b"GET /info HTTP/1.1\r\nUser-Agent: AirPlay/540.31\r\nContent-Length: 0\r\n\r\n"; + let reply = chan.request(info).await?; + let head = String::from_utf8_lossy(&reply); + println!( + " ✅ {} ({} bytes decrypted)", + head.lines().next().unwrap_or(""), + reply.len() + ); + + println!("\n── endpoints, over the encrypted channel"); + let probes: Vec<(&str, Vec)> = vec![ + ( + "POST /stream", + http_session::build_stream_request(1920, 1080, 30, "probe-session")?, + ), + ( + "POST /fp-setup", + b"POST /fp-setup HTTP/1.1\r\nContent-Length: 0\r\n\r\n".to_vec(), + ), + ( + "SETUP (RTSP)", + b"SETUP rtsp://x/stream RTSP/1.0\r\nCSeq: 1\r\nContent-Length: 0\r\n\r\n".to_vec(), + ), + ( + "GET /server-info", + b"GET /server-info HTTP/1.1\r\nContent-Length: 0\r\n\r\n".to_vec(), + ), + ]; + for (name, req) in probes { + match chan.request(&req).await { + Ok(r) => { + let t = String::from_utf8_lossy(&r); + println!(" {:<18} {}", name, t.lines().next().unwrap_or("")); + } + Err(e) => { + println!(" {name:<18} ❌ {e}"); + break; + } + } + } + + println!("\n Reading the result:"); + println!(" • /stream 404 — no legacy AirPlay 1 mirroring endpoint"); + println!(" • /fp-setup 400 not 404 — FairPlay endpoint exists, wants a real body"); + println!(" • SETUP 455 — RTSP works, but needs prior state (fp-setup)"); + println!(" FairPlay is not implemented here by decision; see docs/crypto.md."); + Ok(()) +} diff --git a/crates/openplay-airplay/examples/pair_probe.rs b/crates/openplay-airplay/examples/pair_probe.rs index 2c27d5c..945c17a 100644 --- a/crates/openplay-airplay/examples/pair_probe.rs +++ b/crates/openplay-airplay/examples/pair_probe.rs @@ -83,23 +83,40 @@ async fn main() -> anyhow::Result<()> { // Step 2: the actual question — does SRP-6a agree with real hardware? println!("\n── HAP pair-setup"); + // The two modes end in different places, so they cannot share a result type. + // PIN pairing runs M1-M6 and yields long-term identities; transient stops at + // M4 with only a session key, because there is no identity exchange. let result = match &pin { Some(p) => { println!(" mode: PIN ({p}) — expect a dialog on the receiver\n"); - openplay_airplay::hap_pairing::pair_setup(addr, p).await + openplay_airplay::hap_pairing::pair_setup(addr, p) + .await + .map(|r| { + format!( + "accessory id: {}\n accessory LTPK: {}\n client LTPK: {}", + r.accessory_id, + hex(&r.accessory_ltpk), + hex(&r.client_ltpk) + ) + }) } None => { println!(" mode: transient (no PIN)\n"); - openplay_airplay::hap_pairing::pair_setup_transient(addr).await + openplay_airplay::hap_pairing::pair_setup_transient(addr) + .await + .map(|s| { + format!( + "session key: {} bytes (no long-term identity)", + s.session_key.len() + ) + }) } }; match result { - Ok(r) => { + Ok(detail) => { println!("\n✅ PAIR-SETUP SUCCEEDED"); - println!(" accessory id: {}", r.accessory_id); - println!(" accessory LTPK: {}", hex(&r.accessory_ltpk)); - println!(" client LTPK: {}", hex(&r.client_ltpk)); + println!(" {detail}"); println!("\n The SRP group is correct and the receiver accepted our M3 proof."); } Err(e) => { diff --git a/crates/openplay-airplay/src/control_channel.rs b/crates/openplay-airplay/src/control_channel.rs new file mode 100644 index 0000000..5a2933e --- /dev/null +++ b/crates/openplay-airplay/src/control_channel.rs @@ -0,0 +1,213 @@ +//! The encrypted AirPlay 2 control channel. +//! +//! After a transient pair-setup completes at M4, the connection stops speaking +//! plaintext HTTP: the receiver closes it on the next unencrypted byte. Every +//! subsequent request and response is carried in ChaCha20-Poly1305 frames keyed +//! from the SRP session key. +//! +//! # Framing +//! +//! Each frame is a 2-byte little-endian plaintext length, then that many bytes +//! of ciphertext, then a 16-byte Poly1305 tag. The length prefix is *also* the +//! AEAD associated data, so a tampered length fails authentication rather than +//! desynchronising the stream. +//! +//! The nonce is a 64-bit little-endian counter zero-padded to 96 bits, counted +//! independently in each direction and never reset for the life of the +//! connection. + +use anyhow::{anyhow, Context, Result}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use sha2::Sha512; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Largest plaintext one frame may carry, per the 2-byte length prefix. +const MAX_FRAME: usize = 0xFFFF; + +/// HKDF salt shared by both control-channel keys. +const CONTROL_SALT: &[u8] = b"Control-Salt"; +/// Info string for the key this side encrypts with. +const WRITE_INFO: &[u8] = b"Control-Write-Encryption-Key"; +/// Info string for the key this side decrypts with. +const READ_INFO: &[u8] = b"Control-Read-Encryption-Key"; + +/// A `TcpStream` carrying encrypted control-channel frames. +pub struct ControlChannel { + stream: TcpStream, + write_cipher: ChaCha20Poly1305, + read_cipher: ChaCha20Poly1305, + write_counter: u64, + read_counter: u64, +} + +impl ControlChannel { + /// Wraps a post-M4 connection, deriving both directional keys from `K`. + pub fn new(stream: TcpStream, session_key: &[u8]) -> Result { + Ok(Self { + stream, + write_cipher: cipher_from(session_key, WRITE_INFO)?, + read_cipher: cipher_from(session_key, READ_INFO)?, + write_counter: 0, + read_counter: 0, + }) + } + + /// Encrypts and sends one frame. + pub async fn send(&mut self, plaintext: &[u8]) -> Result<()> { + if plaintext.len() > MAX_FRAME { + return Err(anyhow!( + "control frame of {} bytes exceeds the {MAX_FRAME}-byte limit", + plaintext.len() + )); + } + + let len = (plaintext.len() as u16).to_le_bytes(); + let sealed = self + .write_cipher + .encrypt( + &nonce_for(self.write_counter), + Payload { + msg: plaintext, + aad: &len, + }, + ) + .map_err(|e| anyhow!("control frame encryption failed: {e}"))?; + self.write_counter += 1; + + self.stream.write_all(&len).await?; + self.stream.write_all(&sealed).await?; + self.stream.flush().await?; + Ok(()) + } + + /// Reads and decrypts one frame. + pub async fn recv(&mut self) -> Result> { + let mut len_buf = [0u8; 2]; + self.stream + .read_exact(&mut len_buf) + .await + .context("control channel closed while reading a frame length")?; + let len = u16::from_le_bytes(len_buf) as usize; + + // Ciphertext is the same length as the plaintext, plus the tag. + let mut sealed = vec![0u8; len + 16]; + self.stream + .read_exact(&mut sealed) + .await + .context("control channel closed mid-frame")?; + + let plaintext = self + .read_cipher + .decrypt( + &nonce_for(self.read_counter), + Payload { + msg: &sealed, + aad: &len_buf, + }, + ) + .map_err(|e| anyhow!("control frame authentication failed: {e}"))?; + self.read_counter += 1; + + Ok(plaintext) + } + + /// Sends a request and reads frames until a complete HTTP response is held. + pub async fn request(&mut self, request: &[u8]) -> Result> { + self.send(request).await?; + + let mut buf = Vec::new(); + loop { + buf.extend_from_slice(&self.recv().await?); + + let Some(split) = find_header_end(&buf) else { + continue; + }; + let want = content_length(&buf[..split]).unwrap_or(0); + if buf.len() - split >= want { + return Ok(buf); + } + } + } +} + +/// Derives one directional key and builds its cipher. +fn cipher_from(session_key: &[u8], info: &[u8]) -> Result { + let hk = Hkdf::::new(Some(CONTROL_SALT), session_key); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .map_err(|e| anyhow!("control key derivation failed: {e}"))?; + Ok(ChaCha20Poly1305::new(&key.into())) +} + +/// 64-bit little-endian counter, zero-padded to the 96-bit nonce. +fn nonce_for(counter: u64) -> Nonce { + let mut nonce = [0u8; 12]; + nonce[4..].copy_from_slice(&counter.to_le_bytes()); + *Nonce::from_slice(&nonce) +} + +/// Offset just past the blank line ending the headers. +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +/// Parses `Content-Length` from a header block. +fn content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines() + .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) + .and_then(|l| l.split(':').nth(1)) + .and_then(|v| v.trim().parse().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nonce_is_a_little_endian_counter_in_the_high_eight_bytes() { + assert_eq!(nonce_for(0).as_slice(), &[0u8; 12]); + let n1 = nonce_for(1); + assert_eq!(&n1.as_slice()[..4], &[0, 0, 0, 0], "leading padding"); + assert_eq!(&n1.as_slice()[4..], &1u64.to_le_bytes()); + assert_eq!(&nonce_for(258).as_slice()[4..], &258u64.to_le_bytes()); + } + + #[test] + fn the_two_directional_keys_differ() { + // Same session key, different info strings — deriving one key for both + // directions would decrypt our own writes and never the peer's. + let k = [7u8; 64]; + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + Hkdf::::new(Some(CONTROL_SALT), &k) + .expand(WRITE_INFO, &mut a) + .unwrap(); + Hkdf::::new(Some(CONTROL_SALT), &k) + .expand(READ_INFO, &mut b) + .unwrap(); + assert_ne!(a, b); + } + + #[test] + fn header_end_and_content_length_parse() { + let msg = b"HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\nbody"; + let split = find_header_end(msg).unwrap(); + assert_eq!(&msg[split..], b"body"); + assert_eq!(content_length(&msg[..split]), Some(42)); + } + + #[test] + fn content_length_is_case_insensitive_and_optional() { + let lower = b"HTTP/1.1 200 OK\r\ncontent-length: 7\r\n\r\n"; + let split = find_header_end(lower).unwrap(); + assert_eq!(content_length(&lower[..split]), Some(7)); + + let none = b"HTTP/1.1 200 OK\r\nServer: AirTunes\r\n\r\n"; + let split = find_header_end(none).unwrap(); + assert_eq!(content_length(&none[..split]), None); + } +} diff --git a/crates/openplay-airplay/src/hap_pairing.rs b/crates/openplay-airplay/src/hap_pairing.rs index c6975a7..6fbfcd0 100644 --- a/crates/openplay-airplay/src/hap_pairing.rs +++ b/crates/openplay-airplay/src/hap_pairing.rs @@ -82,22 +82,50 @@ pub struct PairedDevice { /// receiver *does* require is the `X-Apple-HKP` header — see `HKP_TRANSIENT`. /// /// Returns a PairSetupResult that can be used for pair-verify. -pub async fn pair_setup_transient(addr: SocketAddr) -> anyhow::Result { - pair_setup_internal(addr, "3939", true).await +pub async fn pair_setup_transient(addr: SocketAddr) -> anyhow::Result { + let (stream, session_key) = pair_setup_srp(addr, "3939", true).await?; + info!("Transient pair-setup complete — session key established"); + Ok(TransientSession { + session_key, + stream, + }) +} + +/// Outcome of a *transient* pair-setup. +/// +/// Transient pairing ends at M4. There is no M5/M6 identity exchange, so no +/// long-term key pair exists, nothing is persisted, and there is no pair-verify +/// to perform — the SRP session key keys the encrypted channel directly. +/// +/// Running M5 anyway is what used to happen here, and the receiver responded by +/// closing the connection immediately after a successful M4. +/// +/// The key is only meaningful on the connection it was negotiated over, so the +/// stream is handed back rather than dropped. Everything sent on it from this +/// point must be encrypted; the receiver drops the connection on plaintext. +pub struct TransientSession { + /// Shared SRP session key (`K`). + pub session_key: Vec, + /// The connection the key belongs to. + pub stream: TcpStream, } /// Perform pair-setup with an AirPlay receiver using a 4-digit PIN. /// /// This is the first-time pairing flow using SRP-6a. pub async fn pair_setup(addr: SocketAddr, pin: &str) -> anyhow::Result { - pair_setup_internal(addr, pin, false).await + let (stream, session_key) = pair_setup_srp(addr, pin, false).await?; + pair_setup_identity_exchange(stream, &session_key).await } -async fn pair_setup_internal( +/// Runs pair-setup M1-M4 (the SRP half) and returns the connection plus the +/// negotiated session key. Both pairing modes share this; only the PIN flow +/// continues into M5/M6. +async fn pair_setup_srp( addr: SocketAddr, pin: &str, transient: bool, -) -> anyhow::Result { +) -> anyhow::Result<(TcpStream, Vec)> { let mut stream = TcpStream::connect(addr).await?; info!(%addr, transient, "Starting HAP pair-setup"); @@ -163,10 +191,21 @@ async fn pair_setup_internal( } info!("SRP-6a verification successful"); + Ok((stream, session_key)) +} + +/// Pair-setup M5/M6: exchange and verify long-term identities. +/// +/// PIN pairing only. Transient pairing has no identity to exchange and must not +/// reach this. +async fn pair_setup_identity_exchange( + mut stream: TcpStream, + session_key: &[u8], +) -> anyhow::Result { // Derive encryption key for M5/M6 exchange let enc_key = hkdf_derive( b"Pair-Setup-Encrypt-Salt", - &session_key, + session_key, b"Pair-Setup-Encrypt-Info", 32, )?; @@ -178,7 +217,7 @@ async fn pair_setup_internal( // Derive iOSDeviceX let device_x = hkdf_derive( b"Pair-Setup-Controller-Sign-Salt", - &session_key, + session_key, b"Pair-Setup-Controller-Sign-Info", 32, )?; @@ -241,7 +280,7 @@ async fn pair_setup_internal( // Verify accessory signature let accessory_x = hkdf_derive( b"Pair-Setup-Accessory-Sign-Salt", - &session_key, + session_key, b"Pair-Setup-Accessory-Sign-Info", 32, )?; diff --git a/crates/openplay-airplay/src/http_session.rs b/crates/openplay-airplay/src/http_session.rs index 2d6e629..1178a63 100644 --- a/crates/openplay-airplay/src/http_session.rs +++ b/crates/openplay-airplay/src/http_session.rs @@ -317,6 +317,52 @@ pub async fn get_info_raw( /// Sends POST /stream with AirPlay headers on an already-open stream. /// Used by the auth flow after pair-verify. +/// Builds a complete `POST /stream` request — headers and binary-plist body. +/// +/// Split out so the same request can be sent either straight down a socket or +/// wrapped in encrypted control-channel frames after a transient pair-setup. +pub fn build_stream_request( + width: u32, + height: u32, + fps: u32, + session_id: &str, +) -> Result, AirPlayError> { + let mut params = BTreeMap::new(); + params.insert("width".to_string(), plist::Value::Integer(width.into())); + params.insert("height".to_string(), plist::Value::Integer(height.into())); + params.insert("fps".to_string(), plist::Value::Integer(fps.into())); + params.insert("overscanned".to_string(), plist::Value::Boolean(false)); + params.insert("refreshRate".to_string(), plist::Value::Real(fps as f64)); + params.insert( + "sessionID".to_string(), + plist::Value::String(session_id.to_string()), + ); + params.insert( + "version".to_string(), + plist::Value::String("1.0".to_string()), + ); + + let plist_value = plist::Value::Dictionary(params.into_iter().collect()); + let mut body = Vec::new(); + plist_value + .to_writer_binary(&mut body) + .map_err(|e| AirPlayError::Plist(format!("Failed to encode plist: {e}")))?; + + let mut request = format!( + "POST /stream HTTP/1.1\r\n\ + User-Agent: {AIRPLAY_USER_AGENT}\r\n\ + X-Apple-Device-Name: {OPENPLAY_DEVICE_NAME}\r\n\ + X-Apple-Session-ID: {session_id}\r\n\ + X-Apple-ProtocolVersion: 1\r\n\ + Content-Type: application/x-apple-binary-plist\r\n\ + Content-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + request.extend_from_slice(&body); + Ok(request) +} + pub async fn post_stream_on( stream: &mut TcpStream, width: u32, diff --git a/crates/openplay-airplay/src/lib.rs b/crates/openplay-airplay/src/lib.rs index 83c6de9..7ddad71 100644 --- a/crates/openplay-airplay/src/lib.rs +++ b/crates/openplay-airplay/src/lib.rs @@ -15,6 +15,7 @@ //! interoperate. Receivers that require it, such as Apple TV 3rd gen, will //! reject the handshake. See [`fairplay`]. +pub mod control_channel; pub mod fairplay; pub mod features; pub mod hap_pairing; diff --git a/crates/openplay-airplay/src/session.rs b/crates/openplay-airplay/src/session.rs index 8e1b13c..d69024c 100644 --- a/crates/openplay-airplay/src/session.rs +++ b/crates/openplay-airplay/src/session.rs @@ -6,6 +6,7 @@ use tokio::sync::{mpsc, Mutex}; use tokio::time::{interval, Duration}; use tracing::{error, info, warn}; +use crate::control_channel; use crate::hap_pairing; use crate::http_session; use crate::mirror_stream::MirrorStream; @@ -225,33 +226,49 @@ async fn negotiate_with_auth( // Step 3: Try HAP transient pair-setup (no PIN, for AirPlay 2 devices) info!("Attempting HAP transient pairing (no PIN)"); - let pair_result = hap_pairing::pair_setup_transient(receiver_addr) + let session = hap_pairing::pair_setup_transient(receiver_addr) .await .map_err(|e| AirPlayError::Pairing(format!("Transient pairing failed: {e}")))?; - info!("Transient pair-setup succeeded, starting pair-verify"); + info!("Transient pair-setup succeeded"); - // Step 4: pair-verify to establish encrypted session - let paired_device = hap_pairing::PairedDevice { - device_id: pair_result.accessory_id.clone(), - accessory_ltpk: pair_result.accessory_ltpk, - client_ltsk: pair_result.client_ltsk, - client_ltpk: pair_result.client_ltpk, - }; + // Step 4: everything after M4 is encrypted. There is no pair-verify in the + // transient flow — that belongs to PIN pairing, which exchanges long-term + // identities in M5/M6. Transient has none; the SRP session key keys the + // channel directly, and only on the connection it was negotiated over. + let mut control = control_channel::ControlChannel::new(session.stream, &session.session_key) + .map_err(|e| AirPlayError::Pairing(format!("Control channel setup failed: {e}")))?; - let (mut verified_stream, _verify_result) = - hap_pairing::pair_verify(receiver_addr, &paired_device) - .await - .map_err(|e| AirPlayError::Pairing(format!("Pair-verify failed: {e}")))?; + info!("Encrypted control channel established, sending POST /stream"); - info!("Pair-verify succeeded, sending POST /stream"); + let request = http_session::build_stream_request(width, height, fps, session_id)?; + let response = control + .request(&request) + .await + .map_err(|e| AirPlayError::Negotiation(format!("Encrypted POST /stream failed: {e}")))?; - // Step 5: POST /stream on the verified connection (with proper AirPlay headers) - http_session::post_stream_on(&mut verified_stream, width, height, fps, session_id).await?; - info!("POST /stream accepted after HAP pairing — mirror stream active"); + let status = String::from_utf8_lossy(&response); + let status_line = status.lines().next().unwrap_or(""); + if !status_line.contains("200") { + return Err(AirPlayError::Negotiation(format!( + "POST /stream over the encrypted channel returned: {status_line}" + ))); + } - Ok(http_session::NegotiatedStream { - stream: verified_stream, - server_info, - }) + info!("POST /stream accepted over the encrypted control channel"); + + // Step 5: and here the implemented path ends. `MirrorStream` writes NAL + // units straight to a `TcpStream`, but every byte on this connection must + // now be wrapped in control-channel frames, so handing it the raw socket + // would emit plaintext into an encrypted stream and the receiver would drop + // the connection. Making the mirror stream encryption-aware is the + // remaining work; failing here with an explicit message beats returning a + // connection that cannot carry video. + let _ = server_info; + Err(AirPlayError::Negotiation( + "Transient pairing and the encrypted control channel now succeed, but the \ + mirror stream cannot yet send video over an encrypted connection. See the \ + AirPlay status in docs/crypto.md." + .to_string(), + )) } diff --git a/crates/openplay-airplay/src/srp.rs b/crates/openplay-airplay/src/srp.rs index a7bafc5..b8bb1f7 100644 --- a/crates/openplay-airplay/src/srp.rs +++ b/crates/openplay-airplay/src/srp.rs @@ -165,9 +165,21 @@ pub fn client_compute( }; // M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K) + // + // `H(g)` is taken over g's *minimal* big-endian encoding — a single 0x05 + // byte — not over PAD(g). This differs from `k` above, which RFC 5054 + // explicitly defines as H(N | PAD(g)); the proof has no such padding rule + // and Apple's receivers do not apply one. + // + // Padding it here was why pair-setup failed at M4 with HAP error 2 + // (authentication) against real hardware, even though every value in the + // exchange was otherwise correct. A client/server round-trip cannot catch + // this: both halves agree on the same wrong digest and the test passes. + // Verified against a Mac running AirTunes/950.7.1 — minimal encoding + // completes pair-setup, PAD(g) is rejected. let m1 = { let hash_n = Sha512::digest(n.to_bytes_be()); - let hash_g = Sha512::digest(pad_to_n(&g, &n)); + let hash_g = Sha512::digest(g.to_bytes_be()); let hash_xor: Vec = hash_n .iter() .zip(hash_g.iter()) @@ -405,7 +417,7 @@ mod tests { let m1 = { let hash_n = Sha512::digest(self.n.to_bytes_be()); - let hash_g = Sha512::digest(pad_to_n(&self.g, &self.n)); + let hash_g = Sha512::digest(self.g.to_bytes_be()); let xor: Vec = hash_n .iter() .zip(hash_g.iter())