Skip to content
Open
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ CI runs `fmt --check`, then `clippy -D warnings`, then `cargo test --all`, then
Do not assume a feature works because a type for it exists. As of this writing:

- **Miracast sending** works, including Wi-Fi Direct P2P on Linux.
- **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`.
- **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. Apple TV 2nd/3rd generation are turned away by the advertised transient-pairing feature bit, **not** by model string: the string is self-reported and third-party receivers reuse it. 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. `openplay-crypto` depends on `rustls` again: #34 added the signaling channel's TLS config builders in `tls.rs`, and they have no callers either.
Expand All @@ -77,7 +77,7 @@ Steps 1–5 describe the design. Neither binary performs any of it yet.

1. Sender discovers AirPlay receivers via `AirPlayBrowser` (discovery).
2. User selects a receiver; `start_airplay_cast` in `casting.rs` is called.
3. `AirPlaySession` (airplay/session.rs) spawns `run_session`, which starts an NTP server on port 7010, then tries `http_session::negotiate` (`GET /info` → `POST /stream`). On 501/403 it falls back to `negotiate_with_auth`: HAP **transient** pair-setup + pair-verify (airplay/hap_pairing.rs, SRP-6a math in airplay/srp.rs), then `POST /stream`. **There is no FairPlay phase** — `fairplay.rs` has no callers, and `AppleTV2,*`/`AppleTV3,*` are refused up front instead.
3. `AirPlaySession` (airplay/session.rs) spawns `run_session`, which starts an NTP server on port 7010, then tries `http_session::negotiate` (`GET /info` → `POST /stream`). On a `401`/`403`/`404`/`470`/`501` **status line** it falls back to `negotiate_with_auth`: HAP **transient** pair-setup (airplay/hap_pairing.rs, SRP-6a math in airplay/srp.rs), then `POST /stream` over the encrypted control channel. The status code is parsed from the first line only — `AirPlayError::Negotiation` carries the whole header block, and `Content-Length: 1401` must not read as a `401`. **There is no FairPlay phase** — `fairplay.rs` has no callers, and a receiver that does not advertise transient-pairing support is refused before pairing is attempted.
4. `AirPlaySenderPipeline` (pipeline) captures screen → encodes H.264 → emits NAL units to an appsink.
5. The casting loop reads NAL units, copies the SPS/PPS out of the first frame and sends them once as codec data (`send_codec_data`), then forwards every access unit unmodified via `AirPlaySession::send_video_frame` (airplay/mirror_stream.rs).

Expand Down
15 changes: 11 additions & 4 deletions crates/openplay-airplay/src/http_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,18 @@ async fn post_stream(
.await
.map_err(|e| AirPlayError::Http(format!("Failed to send POST /stream body: {e}")))?;

// Read response status
let (status, _body) = read_http_response(stream).await?;
if !status.contains("200") {
// Read response status.
//
// `status` is the whole header block, so it must be matched a line at a
// time: `Content-Length: 1200` in a 500 response contains "200", and
// treating that as success would hand the caller a failed connection to
// write video into. The status line is also what the caller's retry logic
// parses, so it has to lead the message.
let (headers, _body) = read_http_response(stream).await?;
let status_line = headers.lines().next().unwrap_or("<no status line>");
if !status_line.contains(" 200") {
return Err(AirPlayError::Negotiation(format!(
"POST /stream failed: {status}"
"POST /stream failed: {status_line}"
)));
}

Expand Down
231 changes: 207 additions & 24 deletions crates/openplay-airplay/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,19 @@ async fn run_session(
info!("NTP server running on port {}", AIRPLAY_NTP_PORT);

// Step 2: HTTP negotiate — try basic first, then authenticated if needed
let negotiated = match http_session::negotiate(receiver_addr, width, height, fps, &session_id)
.await
{
Ok(n) => {
info!("AirPlay negotiation complete (no auth required)");
n
}
Err(AirPlayError::Negotiation(ref msg)) if msg.contains("501") || msg.contains("403") => {
// Server requires authentication — try HAP pairing
info!("Receiver requires authentication, attempting HAP pairing");
negotiate_with_auth(receiver_addr, width, height, fps, &session_id).await?
}
Err(e) => return Err(e),
};
let negotiated =
match http_session::negotiate(receiver_addr, width, height, fps, &session_id).await {
Ok(n) => {
info!("AirPlay negotiation complete (no auth required)");
n
}
Err(AirPlayError::Negotiation(ref msg)) if wants_authentication(msg) => {
// Server requires authentication — try HAP pairing
info!("Receiver requires authentication, attempting HAP pairing");
negotiate_with_auth(receiver_addr, width, height, fps, &session_id).await?
}
Err(e) => return Err(e),
};

// Step 3: Create mirror stream (optionally with FairPlay encryption)
let mirror_stream = Arc::new(Mutex::new(MirrorStream::new(negotiated.stream)));
Expand Down Expand Up @@ -190,13 +189,49 @@ async fn run_session(
Ok(())
}

/// Whether an unauthenticated `POST /stream` failure is worth retrying behind
/// pairing.
///
/// This was `msg.contains("501") || msg.contains("403")`, which missed the
/// statuses real receivers actually answer with. A Mac with AirPlay Receiver
/// set to *Everyone* and no password returns **404** — the legacy AirPlay 1
/// endpoint simply does not exist there — and one with a password returns
/// **470**. Neither matched, so casting gave up without ever attempting to
/// pair, while `pair_probe` reached M4 happily by calling pair-setup directly.
///
/// The code is read from the **status line only**. Substring-matching the whole
/// message cannot work: `AirPlayError::Negotiation` carries the entire header
/// block, so `Content-Length: 1401` in a permanent `500` would be read as a
/// `401` and provoke a full SRP-6a pair-setup against a receiver that was never
/// going to authenticate. `Server: AirTunes/470.x` does the same. Anything past
/// the first line is a header, and headers are full of digits.
fn wants_authentication(msg: &str) -> bool {
matches!(status_code(msg), Some(401 | 403 | 404 | 470 | 501))
}

/// Extracts the HTTP status code from a message whose first line is a status
/// line, e.g. `POST /stream failed: HTTP/1.1 404 Not Found`.
///
/// Returns `None` for anything that is not a status line at all — a connection
/// error, a timeout — so those never provoke a retry.
fn status_code(msg: &str) -> Option<u16> {
let status_line = msg.lines().next()?;
let after_version = status_line
.split("HTTP/1.1")
.nth(1)
.or_else(|| status_line.split("HTTP/1.0").nth(1))?;
after_version.split_whitespace().next()?.parse().ok()
}

/// Negotiate AirPlay connection with authentication.
///
/// Strategy:
/// 1. GET /info to identify the device
/// 2. If Apple TV 3rd gen (AppleTV3,x) → error (requires proprietary FairPlay)
/// 3. If device supports transient pairing → HAP transient pair-setup + pair-verify
/// 4. POST /stream on the verified connection
/// 2. Warn — but do not refuse — on models that normally require FairPlay
/// 3. Refuse only on the advertised feature bit: no transient pairing support
/// means the handshake below cannot succeed, whatever the model string says
/// 4. HAP transient pair-setup
/// 5. POST /stream over the encrypted control channel
async fn negotiate_with_auth(
receiver_addr: SocketAddr,
width: u32,
Expand All @@ -216,23 +251,59 @@ async fn negotiate_with_auth(
let model = &server_info.model;
info!(model = %model, features = server_info.features.raw(), "Checking auth strategy");

// Step 2: Check if this is an Apple TV 3rd gen (FairPlay-only, not supported)
// Step 2: warn about hardware known to need FairPlay, but do not refuse on
// the model string alone.
//
// The string is self-reported and third-party receivers borrow it freely for
// client compatibility. A Vivitek NovoConnect on the test network advertises
// `AppleTV3,1` over mDNS and reports `AppleTV3,2` from GET /info while being
// neither: `rmodel=AirReceiver3,1`, no HomeKit pairing, and a `/fp-setup`
// that answers 200 to an empty body — nothing like the Apple hardware this
// check was written for. Refusing it up front meant never discovering that
// it authenticates fine and simply does not implement mirroring.
//
// Genuine Apple TV 2/3 still cannot work; the feature-bit check below is
// what turns them away, with a reason the user can act on.
if model.starts_with("AppleTV3") || model.starts_with("AppleTV2") {
return Err(AirPlayError::Negotiation(format!(
"{model} requires FairPlay authentication which is not supported. \
Use a newer Apple TV (4th gen+) or an AirPlay-compatible smart TV instead."
warn!(
%model,
"This model normally requires FairPlay, which is not implemented. \
Continuing anyway — the string is self-reported and third-party \
receivers reuse it. Expect failure from the receiver if it is genuine."
);
}

// Step 3: refuse on evidence rather than on the model string.
//
// Feature bit 48 is the receiver's own statement about whether it supports
// transient pairing, and it is the only thing here that actually predicts
// whether step 4 can work. Checking it is what the model-string refusal was
// reaching for: a genuine Apple TV 2/3 does not set it and is turned away
// with a specific reason, while the Vivitek-style third-party boxes that
// merely borrow the model string are not.
if !server_info.features.supports_transient_pairing() {
let hint = if model.starts_with("AppleTV3") || model.starts_with("AppleTV2") {
" Apple TV 2nd and 3rd generation need FairPlay, which OpenPlay does \
not implement and will not — see docs/crypto.md. Use a 4th generation \
or later, or an AirPlay-compatible smart TV."
} else {
""
};
return Err(AirPlayError::Pairing(format!(
"{model} does not advertise transient pairing support (feature bit 48), \
so the no-PIN handshake OpenPlay implements cannot authenticate to it.{hint}"
)));
}

// Step 3: Try HAP transient pair-setup (no PIN, for AirPlay 2 devices)
// Step 4: Try HAP transient pair-setup (no PIN, for AirPlay 2 devices)
info!("Attempting HAP transient pairing (no PIN)");
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");

// Step 4: everything after M4 is encrypted. There is no pair-verify in the
// Step 5: 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.
Expand All @@ -257,7 +328,7 @@ async fn negotiate_with_auth(

info!("POST /stream accepted over the encrypted control channel");

// Step 5: and here the implemented path ends. `MirrorStream` writes NAL
// Step 6: 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
Expand All @@ -272,3 +343,115 @@ async fn negotiate_with_auth(
.to_string(),
))
}

#[cfg(test)]
mod auth_fallback_tests {
use super::{status_code, wants_authentication};

/// What `AirPlayError::Negotiation` actually carries: the whole header
/// block, not just the status line. Every test below uses this shape,
/// because the single-line strings the first version of these tests used
/// were the reason the header-matching bug survived them.
fn negotiation_failure(status_line: &str, headers: &[&str]) -> String {
let mut msg = format!("POST /stream failed: {status_line}");
for header in headers {
msg.push_str("\r\n");
msg.push_str(header);
}
msg
}

/// The statuses observed from real receivers that the original
/// `501 || 403` test missed, and which caused casting to skip pairing.
#[test]
fn retries_on_statuses_real_receivers_actually_send() {
assert!(
wants_authentication(&negotiation_failure(
"HTTP/1.1 404 Not Found",
&["Content-Length: 0", "Server: AirTunes/950.7.1"]
)),
"a Mac set to Everyone with no password answers 404"
);
assert!(
wants_authentication(&negotiation_failure(
"HTTP/1.1 470 ",
&["Content-Length: 32"]
)),
"a Mac with Require Password answers 470"
);
assert!(
wants_authentication(&negotiation_failure(
"HTTP/1.1 401 Unauthorized",
&["WWW-Authenticate: Digest realm=\"airplay\""]
)),
"a receiver behind HTTP Digest answers 401"
);
}

#[test]
fn still_retries_on_the_original_two() {
assert!(wants_authentication(&negotiation_failure(
"HTTP/1.1 501 Not Implemented",
&["Content-Length: 0"]
)));
assert!(wants_authentication(&negotiation_failure(
"HTTP/1.1 403 Forbidden",
&["Content-Length: 0"]
)));
}

/// The regression this rewrite exists for.
///
/// Every one of these is a permanent failure whose *headers* contain a
/// retryable code. Substring-matching the message provoked a full SRP-6a
/// pair-setup against a receiver that was never going to authenticate, and
/// replaced an accurate error with a misleading pairing one.
#[test]
fn a_retryable_code_inside_a_header_does_not_trigger_a_retry() {
assert!(
!wants_authentication(&negotiation_failure(
"HTTP/1.1 500 Internal Server Error",
&["Content-Length: 1401", "Connection: close"]
)),
"Content-Length: 1401 contains 401"
);
assert!(
!wants_authentication(&negotiation_failure(
"HTTP/1.1 400 Bad Request",
&["Content-Length: 404"]
)),
"Content-Length: 404 contains 404"
);
assert!(
!wants_authentication(&negotiation_failure(
"HTTP/1.1 500 Internal Server Error",
&["Server: AirTunes/470.8.1"]
)),
"a version string can contain 470"
);
assert!(
!wants_authentication(&negotiation_failure(
"HTTP/1.1 200 OK",
&["Date: Mon, 01 Jan 2024 05:01:03 GMT"]
)),
"a date can contain 501"
);
}

#[test]
fn does_not_retry_on_unrelated_failures() {
assert!(!wants_authentication("Connection refused (os error 61)"));
assert!(!wants_authentication("connection closed while reading"));
assert!(!wants_authentication(""));
}

#[test]
fn status_code_reads_only_the_first_line() {
assert_eq!(
status_code("POST /stream failed: HTTP/1.1 404 Not Found\r\nContent-Length: 401"),
Some(404)
);
assert_eq!(status_code("HTTP/1.0 501 Not Implemented"), Some(501));
assert_eq!(status_code("Connection refused"), None);
}
}
24 changes: 19 additions & 5 deletions docs/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,25 @@ Real FairPlay uses Apple's fixed key tables and a specific challenge-response
transform. Neither is present, so every key derived here would be wrong.

**The module is not wired in.** `fp_setup` has no callers: `session.rs` never
references `fairplay.rs`. Instead `negotiate_with_auth` reads the model string
from `/info` and refuses `AppleTV2,*` / `AppleTV3,*` up front with an explicit
"requires FairPlay authentication which is not supported" error. That is a better
failure than a mysterious reset, and it means the placeholder keys are never
actually put on the wire.
references `fairplay.rs`. That, and only that, is what keeps the placeholder keys
off the wire — the model-string check described below never had anything to do
with it.

`negotiate_with_auth` used to *refuse* `AppleTV2,*` / `AppleTV3,*` outright on
the model string read from `/info`. It no longer does, because the premise was
wrong: the string is self-reported and third-party receivers borrow it freely
for client compatibility. A Vivitek NovoConnect on the test network advertises
`AppleTV3,1` over mDNS and reports `AppleTV3,2` from `/info` while being neither
Apple nor FairPlay-gated — it has no HomeKit pairing and a `/fp-setup` that
answers 200 to an empty body. Refusing it meant never discovering that it
authenticates fine and simply does not implement mirroring.

What replaced the refusal is a check on evidence rather than on a name: feature
bit 48, the receiver's own statement about whether it supports transient
pairing. A genuine Apple TV 2/3 does not set it and is turned away with a
specific reason that names FairPlay and points here; a third-party box that
merely reuses the model string is not. The model string now produces a warning
only.

`fp_setup` does log a warning on entry (`fairplay.rs:94`) saying it cannot
interoperate — but since nothing calls it, that warning never fires. Treat the
Expand Down
Loading