Skip to content

fix(airplay): complete transient pairing and add the encrypted control channel - #42

Merged
Developer1010x merged 2 commits into
fix/airplay-hkp-headerfrom
fix/airplay-srp-proof-hash-g
Aug 26, 2026
Merged

fix(airplay): complete transient pairing and add the encrypted control channel#42
Developer1010x merged 2 commits into
fix/airplay-hkp-headerfrom
fix/airplay-srp-proof-hash-g

Conversation

@snadahalli

Copy link
Copy Markdown
Owner

Stacked on #41 — review that first; this PR's base retargets to master once it merges.

This answers #27. Pair-setup now completes against real Apple hardware, and the encrypted control channel works. It does not produce a working cast, and the reason is at the bottom.

1. The SRP proof hashed g padded

Pair-setup reached M4 and the receiver rejected our proof with HAP error 2. Everything else was right — group, salt, 384-byte server key, session key.

M1 = H( H(N) XOR H(g) | H(I) | s | A | B | K )

H(g) was computed over PAD(g) — g left-padded to N's 384 bytes. It must be g's minimal encoding: the single byte 0x05.

This is an easy mistake to make honestly, because the neighbouring term does require padding: RFC 5054 defines k = H(N | PAD(g)), and that stays padded. The proof has no such rule.

A round-trip test cannot catch this. Both halves compute the same wrong digest and agree — the identical blind spot that let the fabricated group in #23 survive its own round-trip. So the fix changes the test-side server implementation too, keeping the test a proof of agreement rather than a fresh mutual delusion.

Verified: with PAD(g) → error 2; with minimal encoding → SRP-6a verification successful.

2. Transient pairing ran M5/M6

After a successful M4 the code always continued into the Ed25519 identity exchange. Transient pairing ends at M4 — the receiver answered the stray M5 by closing the connection, so a pair-setup that had just succeeded reported Connection closed.

Split the shared SRP half from the identity exchange; transient gets TransientSession { session_key, stream }.

3. There is no pair-verify in the transient flow

negotiate_with_auth ran pair-setup then pair-verify, feeding it long-term keys transient never produces. Pair-verify re-establishes a session from a stored identity; transient has none, and its SRP session key already is the session.

4. The post-M4 channel is encrypted

Verified directly — a plaintext GET /info immediately after M4 gets the connection closed.

control_channel.rs implements it: HKDF-SHA512 from the session key under Control-Salt with separate read/write keys, then ChaCha20-Poly1305 frames of a 2-byte little-endian length. The length is also the AEAD associated data, so a tampered length fails authentication rather than desynchronising the stream. Nonce is a 64-bit little-endian counter, per-direction.

Confirmed by examples/control_probe.rs: pair-setup completes, then an encrypted GET /info decrypts to HTTP/1.1 200 OK, 1395-byte body.

Why this still does not cast

Two independent reasons, and I want both on the record rather than implied by a green build.

The mirror stream cannot encrypt. MirrorStream writes NAL units to a bare TcpStream, but every byte on this connection must now be framed. negotiate_with_auth therefore fails with an explicit message instead of returning a connection that cannot carry video.

The receiver has no endpoint to mirror to. Probed over the working encrypted channel:

Request Response
POST /stream 404 Not Found
POST /fp-setup 400 Bad Request
SETUP (RTSP) 455 Method Not Valid In This State
GET /server-info 200 OK

/fp-setup answering 400 rather than 404 means the FairPlay endpoint exists and merely rejected an empty body. SETUP reporting a state error rather than 404 means the receiver speaks RTSP but expects that FairPlay step first.

So mirroring to this receiver appears to require FairPlay, which is out of scope by decision (docs/crypto.md) — the same decision that refuses Apple TV 2nd/3rd gen. That is a project-level question, not something to resolve in this PR, but it materially changes what "AirPlay support" can mean here.

Verification

fmt --check clean, clippy --all-targets --all-features -D warnings clean, cargo test --all 220 passed / 0 failed, plus 4 new unit tests on the control channel's nonce layout, key separation, and response parsing — and the hardware captures above.

Pair-setup reached M4 and the receiver rejected our client proof with HAP
error 2 (authentication). Every value in the exchange was otherwise correct:
the group, the salt, the 384-byte server public key, the session key.

The M1 proof is

    M1 = H( H(N) XOR H(g) | H(I) | s | A | B | K )

and this code computed `H(g)` over PAD(g) — g left-padded to the 384-byte
length of N. It must be g's minimal big-endian encoding: the single byte 0x05.

The padding is easy to arrive at honestly, because the *neighbouring* term does
require it: RFC 5054 defines k as H(N | PAD(g)), and that stays padded here.
The proof has no such rule, and Apple's receivers apply none.

A client/server round-trip cannot catch this. Both halves compute the same
wrong digest, agree with each other, and the test passes — the same shape of
blind spot that let the fabricated group in #23 survive its own round-trip
test. The fix therefore changes the test-side server implementation too, so
the round-trip continues to prove agreement rather than quietly re-agreeing on
a new wrong value.

Verified against a Mac running AirTunes/950.7.1: with PAD(g) the receiver
answers error 2, with the minimal encoding pair-setup completes and
`pair_probe` reports "SRP-6a verification successful". That answers the
question #27 was opened to ask — the RFC 5054 group installed by #23 is
correct and the handshake works against real hardware.
Two bugs and one missing piece, all on the path between a verified SRP proof
and a working mirror session.

**Transient pairing ran M5/M6.** After a successful M4 the code always
continued into the identity exchange — Ed25519 long-term keys, encrypted
sub-TLVs, the lot. Transient pairing has no identity to exchange and ends at
M4; the receiver responded to the stray M5 by closing the connection, so a
pair-setup that had just succeeded reported "Connection closed". Split the
shared SRP half (M1-M4) from the identity exchange, and give transient its own
`TransientSession { session_key, stream }`.

**There is no pair-verify in the transient flow.** `negotiate_with_auth` did
pair-setup then pair-verify, feeding it long-term keys that transient pairing
never produces. Pair-verify re-establishes a session from a *stored* identity;
transient has none, and its SRP session key already is the session.

**The post-M4 channel is encrypted.** Verified directly: a plaintext
`GET /info` on the connection immediately after M4 gets the connection closed.
`control_channel.rs` implements the transport — HKDF-SHA512 from the session
key under `Control-Salt` with separate read and write keys, then
ChaCha20-Poly1305 frames of a 2-byte little-endian length (which is also the
AEAD associated data, so a tampered length fails authentication instead of
desynchronising the stream) and a 64-bit little-endian nonce counted
independently per direction.

Confirmed end to end against AirTunes/950.7.1 by `examples/control_probe.rs`:
transient pair-setup completes, and an encrypted `GET /info` over the channel
decrypts to `HTTP/1.1 200 OK` with a 1395-byte body.

What this does **not** do is complete a cast, and `negotiate_with_auth` now
says so explicitly rather than handing back a connection that cannot carry
video. `MirrorStream` writes NAL units to a bare `TcpStream`; every byte on
this connection must be framed and encrypted.

The receiver also has no endpoint to mirror to. Probed over the working
encrypted channel:

    POST /stream       404 Not Found                     (no AirPlay 1 endpoint)
    POST /fp-setup     400 Bad Request                   (exists, wants a body)
    SETUP (RTSP)       455 Method Not Valid In This State
    GET /server-info   200 OK

`/fp-setup` answering 400 rather than 404 means the FairPlay endpoint is
present, and RTSP `SETUP` reporting a state error rather than 404 means the
receiver speaks RTSP but expects that FairPlay step first. FairPlay is out of
scope by decision (docs/crypto.md), which bears on how far this path can go.
@Developer1010x
Developer1010x merged commit a4890a8 into fix/airplay-hkp-header Aug 26, 2026
Developer1010x pushed a commit that referenced this pull request Aug 26, 2026
…43)

#42 was merged twelve seconds after #41, into `fix/airplay-hkp-header` —
which #41 had just merged into master and left behind. The merge succeeded,
so nothing looked wrong, but the commits landed on a branch nothing points at
and master never received them.

Master therefore has the `X-Apple-HKP` header from #41 and none of what it was
a prerequisite for: the SRP proof still hashes g padded, transient pairing
still runs M5/M6 and gets the connection closed, and there is no encrypted
control channel. Pairing is broken on master in exactly the way #42 fixed.

This restores #42's own diff — the eight `openplay-airplay` files it actually
touched — on top of current master.

Deliberately *not* a merge of `fix/airplay-hkp-header`. That branch was cut
before #35, #36, #37, #39 and #40 merged, so a diff against it reads as
deleting `openplay-discovery/src/address.rs` and reverting 244 lines of
`openplay-sender/src/app.rs`. Merging it would silently undo five landed
fixes. Only the range between #42 and its own parent is safe to replay, and
that range touches nothing outside `openplay-airplay`.

Verified after the replay: 233 tests pass, clippy and fmt clean, and the work
from #35/#36/#37/#40 is still in the tree.

Co-authored-by: Sandeepa Nadahalli <1698507+snadahalli@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants