From dbc11dcd739a806b4100b37109fb08a1dd24d60f Mon Sep 17 00:00:00 2001 From: Twesh Date: Fri, 31 Jul 2026 12:15:34 -0700 Subject: [PATCH] Use the raw FairPlay key for legacy receivers (#17) ekey wraps the raw fpAesKey, and on a legacy receiver that is the only key material it ever sees, so it decrypts the stream with that key directly. A HAP-paired receiver additionally mixes in the pair-verify secret. The condition chose between them on whether a shared secret existed. rawPairVerify stores one even though it deliberately leaves the channel unencrypted, so the mixing fired on the legacy path too: the sender encrypted with SHA-512(fpAesKey || shared) while the receiver decrypted with fpAesKey. Pairing, /fp-setup, SETUP and RECORD all succeed, frames flow, and the picture stays black -- the symptom reported in #17. Select on c.encrypted instead, which PairVerify sets and rawPairVerify deliberately does not. HAP-paired receivers keep exactly the bytes they get today. Extracts the derivation into deriveStreamMasterKey so it can be tested without a handshake, and adds three tests: the four legacy/HAP-by-secret-presence combinations, a control that the two branches genuinely differ, and a guard that fails if rawPairVerify ever starts setting c.encrypted -- which would silently switch legacy receivers back to the mixed key. Diagnosed from 3rd3's fork patch in #17, which reached the same key by switching unconditionally; that would have dropped the mixing on HAP receivers too. 3rd3 confirmed this conditional version works against an AppleTV3,2 on AirTunes/220.68. --- internal/airplay/fairplay.go | 48 +++++++++--- .../airplay/fairplay_streamkey_helper_test.go | 15 ++++ internal/airplay/fairplay_streamkey_test.go | 76 +++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 internal/airplay/fairplay_streamkey_helper_test.go create mode 100644 internal/airplay/fairplay_streamkey_test.go diff --git a/internal/airplay/fairplay.go b/internal/airplay/fairplay.go index fa8bc07..1dcd5b0 100644 --- a/internal/airplay/fairplay.go +++ b/internal/airplay/fairplay.go @@ -108,17 +108,11 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error { dbg("[FP] wrapped fpAesKey: %02x", fpAesKey[:]) dbg("[FP] m3 first 32 bytes: %02x", c.fpM3[:min(32, len(c.fpM3))]) - // Hash with pair-verify shared secret (ECDH X25519) if available. - // The receiver does: SHA-512(fairplay_decrypt(ekey) || ecdh_secret)[:16] - finalKey := c.fpAesKey - if c.PairKeys != nil && len(c.PairKeys.SharedSecret) > 0 { - h := sha512.New() - h.Write(c.fpAesKey) - h.Write(c.PairKeys.SharedSecret) - finalKey = h.Sum(nil)[:16] - dbg("[FP] hashed with SharedSecret (%d bytes)", len(c.PairKeys.SharedSecret)) + finalKey := deriveStreamMasterKey(c.fpAesKey, sharedSecret(c.PairKeys), c.encrypted) + if c.encrypted && len(sharedSecret(c.PairKeys)) > 0 { + dbg("[FP] hashed with SharedSecret (%d bytes)", len(sharedSecret(c.PairKeys))) } else { - dbg("[FP] using raw fpAesKey (no SharedSecret available)") + dbg("[FP] using raw fpAesKey (legacy receiver or no SharedSecret)") } c.fpKey = finalKey @@ -154,3 +148,37 @@ func (c *AirPlayClient) deriveStreamKeys() error { return nil } + +// sharedSecret returns the pair-verify X25519 secret, or nil. +func sharedSecret(keys *PairKeys) []byte { + if keys == nil { + return nil + } + return keys.SharedSecret +} + +// deriveStreamMasterKey returns the key the receiver will decrypt the stream +// with, given the raw FairPlay key that ekey wraps. +// +// A HAP-paired receiver mixes the pair-verify secret in: +// +// SHA-512(fairplay_decrypt(ekey) || ecdh_secret)[:16] +// +// A legacy receiver does not. ekey wraps the raw key, and that is the only key +// material a legacy receiver ever sees, so it decrypts with that key directly. +// +// hapEncrypted is the discriminator, not the presence of a secret: rawPairVerify +// stores a shared secret even though it deliberately leaves the channel +// unencrypted, so keying off the secret alone mixed it in on the legacy path +// too. The sender then encrypted with SHA-512(key || secret) while the receiver +// decrypted with the raw key -- RTSP setup succeeded and the picture stayed +// black. See issue #17. +func deriveStreamMasterKey(rawKey, secret []byte, hapEncrypted bool) []byte { + if !hapEncrypted || len(secret) == 0 { + return rawKey + } + h := sha512.New() + h.Write(rawKey) + h.Write(secret) + return h.Sum(nil)[:16] +} diff --git a/internal/airplay/fairplay_streamkey_helper_test.go b/internal/airplay/fairplay_streamkey_helper_test.go new file mode 100644 index 0000000..317c0e4 --- /dev/null +++ b/internal/airplay/fairplay_streamkey_helper_test.go @@ -0,0 +1,15 @@ +package airplay + +import ( + "os" + "testing" +) + +func readSource(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return b +} diff --git a/internal/airplay/fairplay_streamkey_test.go b/internal/airplay/fairplay_streamkey_test.go new file mode 100644 index 0000000..d277125 --- /dev/null +++ b/internal/airplay/fairplay_streamkey_test.go @@ -0,0 +1,76 @@ +package airplay + +import ( + "bytes" + "crypto/sha512" + "testing" +) + +// The stream master key must be the raw FairPlay key on a legacy receiver and +// the SHA-512 mixture on a HAP-paired one. The discriminator is whether the +// channel is HAP-encrypted, not whether a shared secret happens to exist -- +// rawPairVerify stores one even though it leaves the channel unencrypted. +func TestDeriveStreamMasterKey(t *testing.T) { + raw := bytes.Repeat([]byte{0xa5}, 16) + secret := bytes.Repeat([]byte{0x5a}, 32) + + h := sha512.New() + h.Write(raw) + h.Write(secret) + mixed := h.Sum(nil)[:16] + + for _, tc := range []struct { + name string + secret []byte + hapEncrypted bool + want []byte + }{ + // The case issue #17 was about: rawPairVerify leaves a shared secret + // behind, but the receiver only ever saw ekey, which wraps the raw key. + {"legacy pairing, secret present", secret, false, raw}, + {"legacy pairing, no secret", nil, false, raw}, + {"HAP pairing", secret, true, mixed}, + {"HAP flagged but no secret", nil, true, raw}, + } { + t.Run(tc.name, func(t *testing.T) { + got := deriveStreamMasterKey(raw, tc.secret, tc.hapEncrypted) + if !bytes.Equal(got, tc.want) { + t.Fatalf("got %x, want %x", got, tc.want) + } + }) + } +} + +// The two branches must not coincide, or the test above proves nothing. +func TestDeriveStreamMasterKeyBranchesDiffer(t *testing.T) { + raw := bytes.Repeat([]byte{0xa5}, 16) + secret := bytes.Repeat([]byte{0x5a}, 32) + if bytes.Equal( + deriveStreamMasterKey(raw, secret, false), + deriveStreamMasterKey(raw, secret, true), + ) { + t.Fatal("legacy and HAP derivations produce the same key") + } +} + +// rawPairVerify must keep leaving the channel unencrypted, since that flag is +// what now selects the derivation. If it ever sets c.encrypted, legacy +// receivers silently regress to the mixed key and the picture goes black again. +func TestRawPairVerifyDoesNotEnableHAPEncryption(t *testing.T) { + if !bytes.Contains(readSource(t, "pairing.go"), []byte("c.PairKeys.SharedSecret = shared")) { + t.Skip("pairing.go no longer stores a shared secret in the expected form") + } + src := readSource(t, "pairing.go") + start := bytes.Index(src, []byte("func (c *AirPlayClient) rawPairVerify")) + if start < 0 { + t.Skip("rawPairVerify not found") + } + body := src[start:] + if end := bytes.Index(body, []byte("\nfunc ")); end > 0 { + body = body[:end] + } + if bytes.Contains(body, []byte("c.encrypted = true")) { + t.Error("rawPairVerify now enables HAP encryption; deriveStreamMasterKey " + + "would switch legacy receivers to the mixed key (see issue #17)") + } +}