Skip to content

Confirm HAP transient pairing against real AirPlay hardware #27

Open

Description

@Developer1010x

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>:7000

Run 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

  1. added a commit that references this issue on Aug 19, 2026
  2. added a commit that references this issue on Aug 26, 2026
  3. snadahalli commented on Sep 3, 2026

    @snadahalli
    Owner

    Answered: pair-setup completes against hardware — outcome 1, after two bugs

    Wire-tested on 2026-08-26 against a Mac running AirTunes/950.7.1 with AirPlay Receiver set to Everyone, and a Vivitek NovoConnect. The SRP exchange now runs to completion:

    SRP-6a verification successful
    

    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:

    1. POST /pair-setup needs an X-Apple-HKP header (value 4 = 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, Host and the FLAGS TLV are both irrelevant here — all three FLAGS variants return a byte-identical M2.
    2. The M1 proof must hash g's minimal encoding (the single byte 0x05), not PAD(g). With PAD(g) the receiver answers HAP error 2 at M4. The neighbouring k = 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-Salt with Control-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 in control_channel.rs and verified working — an encrypted GET /info returns HTTP/1.1 200 OK with 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 0x03 with 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 returns 0x03 must 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:

    • Modern Macs gate mirroring behind FairPlay. /fp-setup answers 400 (so it exists), RTSP SETUP answers 455 (wrong state), /stream is 404. FairPlay remains out of scope by the decision in docs/crypto.md, so this is a wall, not a bug.
    • The NovoConnect advertises AppleTV3,x but is not Apple hardware (check rmodel). 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 /play video only, despite advertising mirroring: true. Feature bits lie.

    Confirming mirroring end to end needs a receiver that implements it without FairPlay; a software receiver such as uxplay is 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,x while 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 table
    • docs/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.

  4. Developer1010x commented on Sep 10, 2026

    @Developer1010x
    OwnerAuthor

    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,1 over mDNS and reports AppleTV3,2 from /info while 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_probe calls pair-setup directly, but the app reached it through a fallback that only triggered on 501/403. A Mac set to Everyone with no password answers 404 (no legacy /stream endpoint) 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 successful pair_probe run 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_probe run against a receiver that accepts the connection, with the output pasted. The 403 from 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 501 to POST /stream with any body, meaning it implements AirPlay playback but no mirroring at all — while advertising mirroring: true. If a probe run looks like that, it is a receiver limitation, not a handshake bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions