From a6316d61c642435bb74b8fdcdbba922825062e05 Mon Sep 17 00:00:00 2001 From: Sandeepa Nadahalli <1698507+snadahalli@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:42 +0530 Subject: [PATCH] fix(airplay): send X-Apple-HKP so pair-setup gets past 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With AirPlay Receiver set to Everyone and no password, `POST /pair-setup` returned 400 Bad Request with an empty body — before the receiver looked at the TLV8 payload at all. `GET /info` succeeded, so this was not access control. Isolated by replaying the request against a Mac running AirTunes/950.7.1, changing one thing at a time: no Host, FLAGS=0x02 u8 (what this code sent) 400 + Host 400 + Host, FLAGS=0x10 u32 400 + Host, X-Apple-HKP: 4, FLAGS=0x10 u32 200, body 409B + Host, X-Apple-HKP: 4, no FLAGS 200, body 409B no Host, X-Apple-HKP: 4, FLAGS=0x10 u32 200, body 409B `X-Apple-HKP` is the only variable that matters: present, the receiver answers; absent, 400 every time. It selects the pairing flow, so without it the endpoint rejects the request outright. `Host` is irrelevant here despite HTTP/1.1 requiring it, and the receiver ignores the `FLAGS` TLV completely. The 409-byte M2 decodes as state + 16-byte salt + 384-byte public key. 384 bytes is 3072 bits, which is independent confirmation that the receiver uses the same RFC 5054 group #23 installed. `pair_probe` now gets a real M2 ("server_pk_len=384 salt_len=16") and fails later, at M4, with HAP error 2 (authentication) — the receiver rejecting our SRP proof. That is a different bug and is not addressed here; see the issue. Note when re-testing: the receiver backs off hard after a failed pair-setup and answers with error 0x03 and a retry delay. Attempts less than a couple of minutes apart return backoff rather than a real answer, which will silently mislead anyone bisecting this. --- crates/openplay-airplay/src/hap_pairing.rs | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/openplay-airplay/src/hap_pairing.rs b/crates/openplay-airplay/src/hap_pairing.rs index 6927a5b..c6975a7 100644 --- a/crates/openplay-airplay/src/hap_pairing.rs +++ b/crates/openplay-airplay/src/hap_pairing.rs @@ -71,8 +71,15 @@ pub struct PairedDevice { /// Perform transient pair-setup with an AirPlay 2 receiver (no PIN required). /// /// Transient pairing is used when the receiver allows "Everyone on the Same Network" -/// access. The flags=0x02 (Transient) tells the receiver that no PIN dialog is needed. -/// Uses SRP-6a with PIN "3939" (standard AirPlay transient code). +/// access. Uses SRP-6a with PIN "3939" (standard AirPlay transient code). +/// +/// The `FLAGS` TLV is sent as `0x02` in a single byte. HAP defines +/// `kPairingFlag_Transient` as `0x00000010` in a uint32, so this value is +/// wrong — but measurement against AirTunes/950.7.1 shows the receiver ignores +/// the TLV entirely: `0x02`, a correct `0x10` uint32, and omitting `FLAGS` +/// altogether all return an identical M2. It is left as-is rather than changed +/// on spec-reading alone, since no receiver is known to care. What the +/// 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 { @@ -392,6 +399,16 @@ pub async fn pair_verify( // --- HTTP helpers for /pair-setup and /pair-verify --- +/// HomeKit pairing type, sent as `X-Apple-HKP`. +/// +/// Without this header the receiver answers **400 Bad Request** before looking +/// at the body at all — it is how the endpoint selects a pairing flow, not an +/// optional hint. Verified against a Mac running AirTunes/950.7.1: with the +/// header, `/pair-setup` returns a real M2 (state, 16-byte salt, 384-byte +/// public key); without it, 400 every time, regardless of `Host` or the value +/// of the transient flag. +const HKP_TRANSIENT: u8 = 4; + async fn send_pair_setup(stream: &mut TcpStream, body: &[u8]) -> anyhow::Result<()> { send_post(stream, "/pair-setup", "application/octet-stream", body).await } @@ -415,9 +432,10 @@ async fn send_post( body: &[u8], ) -> anyhow::Result<()> { let header = format!( - "POST {} HTTP/1.1\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n", - path, - content_type, + "POST {path} HTTP/1.1\r\n\ + X-Apple-HKP: {HKP_TRANSIENT}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\r\n", body.len() ); stream.write_all(header.as_bytes()).await?;