Repository navigation
Confirm HAP transient pairing against real AirPlay hardware #27
Description
Successor to #8, which bundled two very different problems. The FairPlay half is now a settled decision (recorded in docs/crypto.md — it will not be implemented here). This issue is the half that is still genuinely open, and it needs hardware rather than a code change.
The question
hap_pairing.rs originally used a fabricated SRP group — not the RFC 5054 3072-bit group, and not even prime. #23 replaced it with the real group, and srp.rs now pins the group's properties (bit length, primality, safe-primality, generator, and a check against a rearrangement of RFC 3526's formula) separately from the protocol round-trip, since a round-trip test passes happily when both sides share the same wrong modulus.
So the constant is now verifiably correct. What is not known is whether the surrounding handshake actually works, which no unit test in this repository can answer.
What has been tried
examples/pair_probe.rs (added in #26) exists for exactly this:
cargo run -p openplay-airplay --example pair_probe -- <receiver-ip>:7000Run against a MacBook Air (Mac16,12): GET /info succeeds, and every other endpoint returns 403. That is not a crypto failure — macOS defaults its AirPlay Receiver setting to "Current User", which refuses the request before any pairing happens. The probe never reached the SRP exchange, so it produced no evidence either way.
(#26 also made hap_pairing.rs check the HTTP status line before parsing a body, because that 403 was reaching the TLV8 decoder and surfacing as Missing state TLV — a crypto-sounding error for an access-control problem. Same misleading-diagnostic shape as the original bug, one layer up.)
What would close this
A pair_probe run against a receiver that actually accepts the connection — an Apple TV, an AirPlay 2 TV, or a Mac with System Settings → General → AirDrop & Handoff → AirPlay Receiver set to Everyone / Anyone on the same network. Paste the output.
Three outcomes, all useful:
- Pair-setup completes — the SRP fix is confirmed end to end, and the README's "untested against hardware" caveat can finally come off.
- Pair-setup fails past the SRP exchange — a real, newly-actionable bug in the handshake, with a reproduction.
- 403 again — the receiver setting was wrong; retry.
Scope
Explicitly not in scope: FairPlay, and therefore Apple TV 2nd/3rd generation. Those are refused up front by model string and that is intended behaviour, not a gap. See the decision section in docs/crypto.md.
Until this is answered, the AirPlay row in the README stays marked untested. It is one command against the right hardware, and I do not have that hardware — help genuinely wanted.
Activity
docs: record the FairPlay decision, and split #8's open half into #27 (…
docs(airplay): point pair_probe at #27 rather than the split-up #8
docs(airplay): point pair_probe at #27 rather than the split-up #8 (#38)
fix(airplay): complete transient pairing and add the encrypted contro…
snadahalli commentedon Sep 3, 2026
Answered: pair-setup completes against hardware — outcome 1, after two bugs
Wire-tested on 2026-08-26 against a Mac running
AirTunes/950.7.1with AirPlay Receiver set to Everyone, and a Vivitek NovoConnect. The SRP exchange now runs to completion:Two things are worth recording beyond the bare result.
The receiver's public key is 384 bytes. That is 3072 bits, which independently confirms the group #23 installed — the receiver would not have produced a B of that width against a different modulus. The group is now corroborated from the far side of the wire, not just by the self-checks in
srp.rs.It did not work as shipped. Reaching that line took two fixes, both of which were real bugs rather than environment problems:
POST /pair-setupneeds anX-Apple-HKPheader (value4= transient). Without it the receiver returns 400 before parsing the body, so no amount of TLV8 correctness helps. Fixed in fix(airplay): send X-Apple-HKP so pair-setup gets past 400 #41. For the record,Hostand theFLAGSTLV are both irrelevant here — all three FLAGS variants return a byte-identical M2.g's minimal encoding (the single byte0x05), notPAD(g). WithPAD(g)the receiver answers HAP error2at M4. The neighbouringk = H(N | PAD(g))genuinely does require padding, which is how the bug got written in the first place. Fixed in fix(airplay): complete transient pairing and add the encrypted control channel #42, recovered in fix(airplay): restore the transient pairing and control channel work lost in #42 #43.Also learned, and now implemented: transient pairing ends at M4. There is no M5/M6, no long-term keys, no pair-verify. Running M5 makes the receiver close the connection immediately after an otherwise successful M4 — which reads exactly like a crypto failure and is not one. The post-M4 channel is encrypted: HKDF-SHA512 under
Control-SaltwithControl-Write-Encryption-Key/Control-Read-Encryption-Key, then ChaCha20-Poly1305 frames using a 2-byte LE length as AEAD AAD and a per-direction 64-bit LE counter nonce. That channel is implemented incontrol_channel.rsand verified working — an encryptedGET /inforeturnsHTTP/1.1 200 OKwith 1395 bytes of plaintext.A trap that cost a round of testing
The receiver backs off hard after a failed pair-setup, replying HAP error
0x03with a retry delay. Attempts closer together than roughly two minutes return backoff rather than a real answer, and backoff looks nothing like an authentication failure. It silently invalidated a whole round of variant testing before I noticed. Anything that returns0x03must be re-run — treat it as "no result", not as a failure.What this does not establish
Pairing is confirmed. Mirroring is not — and on this network it can't be, for two unrelated reasons:
/fp-setupanswers 400 (so it exists), RTSPSETUPanswers 455 (wrong state),/streamis 404. FairPlay remains out of scope by the decision indocs/crypto.md, so this is a wall, not a bug.AppleTV3,xbut is not Apple hardware (checkrmodel). It authenticates over HTTP Digest (realm="airplay", password = the on-screen PIN — OpenPlay implements no Digest auth at all), and then answers 501 to/stream. It does/playvideo only, despite advertisingmirroring: true. Feature bits lie.Confirming mirroring end to end needs a receiver that implements it without FairPlay; a software receiver such as
uxplayis probably the cheapest way to get one.Scope correction
The issue text says Apple TV 2nd/3rd gen "are refused up front by model string and that is intended behaviour". The NovoConnect is the counter-example: it advertises
AppleTV3,xwhile being a device the refusal was never meant to cover, so the check rejected a receiver we could otherwise talk to. #44 turns that hard refusal into a warning and lets the handshake decide. FairPlay itself stays out of scope — this only changes how we decline, from guessing by model string to reacting to what the receiver actually does.Stale caveats to clear once #44 lands
README.md:67— "untested against hardware" in the status tabledocs/crypto.md:14— "Fixed, untested against hardware"docs/protocols.md:12— "Pairing unconfirmed"Suggest these move to something like "pairing confirmed against hardware; mirroring unconfirmed", which is the accurate state.
Developer1010x commentedon Sep 10, 2026
Still open, and still needs hardware — but #44 moved two things that affect how this should be run and how the answer should be read.
The Scope paragraph goes stale when #44 merges. It says Apple TV 2/3 "are refused up front by model string and that is intended behaviour". #44 removes that refusal, on evidence: a Vivitek NovoConnect on the test network advertises
AppleTV3,1over mDNS and reportsAppleTV3,2from/infowhile being neither Apple nor FairPlay-gated. The string is self-reported and third-party receivers borrow it. What refuses a genuine Apple TV 2/3 now is feature bit 48 — the receiver's own statement about transient-pairing support. The exclusion still holds, but it is no longer a model-string check, and this issue's wording should be updated when #44 lands.#44 also explains a gap this issue skirts.
pair_probecalls pair-setup directly, but the app reached it through a fallback that only triggered on501/403. A Mac set to Everyone with no password answers 404 (no legacy/streamendpoint) and one with a password answers 470 — neither matched, so casting gave up without attempting to pair at all. That is why the probe could get to M4 while the app could not get near it. Worth knowing for anyone testing this: a successfulpair_proberun confirms the SRP fix, but it does not by itself confirm the app reaches pairing. After #44 the two paths agree.What would still close this is unchanged: a
pair_proberun against a receiver that accepts the connection, with the output pasted. The403from a Mac left at the default "Current User" setting remains an access-control answer, not a crypto one — set AirPlay Receiver to Everyone first.Adding one outcome to the three listed, since #44 turned it up: a receiver can authenticate fine and still answer
501toPOST /streamwith any body, meaning it implements AirPlay playback but no mirroring at all — while advertisingmirroring: true. If a probe run looks like that, it is a receiver limitation, not a handshake bug.feat: wire the OpenPlay WebRTC path end to end, behind a consent prom…