Skip to content

Commit dbc11dc

Browse files
TweshTwesh
authored andcommitted
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.
1 parent 8ccea5f commit dbc11dc

3 files changed

Lines changed: 129 additions & 10 deletions

File tree

internal/airplay/fairplay.go

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,11 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
108108
dbg("[FP] wrapped fpAesKey: %02x", fpAesKey[:])
109109
dbg("[FP] m3 first 32 bytes: %02x", c.fpM3[:min(32, len(c.fpM3))])
110110

111-
// Hash with pair-verify shared secret (ECDH X25519) if available.
112-
// The receiver does: SHA-512(fairplay_decrypt(ekey) || ecdh_secret)[:16]
113-
finalKey := c.fpAesKey
114-
if c.PairKeys != nil && len(c.PairKeys.SharedSecret) > 0 {
115-
h := sha512.New()
116-
h.Write(c.fpAesKey)
117-
h.Write(c.PairKeys.SharedSecret)
118-
finalKey = h.Sum(nil)[:16]
119-
dbg("[FP] hashed with SharedSecret (%d bytes)", len(c.PairKeys.SharedSecret))
111+
finalKey := deriveStreamMasterKey(c.fpAesKey, sharedSecret(c.PairKeys), c.encrypted)
112+
if c.encrypted && len(sharedSecret(c.PairKeys)) > 0 {
113+
dbg("[FP] hashed with SharedSecret (%d bytes)", len(sharedSecret(c.PairKeys)))
120114
} else {
121-
dbg("[FP] using raw fpAesKey (no SharedSecret available)")
115+
dbg("[FP] using raw fpAesKey (legacy receiver or no SharedSecret)")
122116
}
123117

124118
c.fpKey = finalKey
@@ -154,3 +148,37 @@ func (c *AirPlayClient) deriveStreamKeys() error {
154148

155149
return nil
156150
}
151+
152+
// sharedSecret returns the pair-verify X25519 secret, or nil.
153+
func sharedSecret(keys *PairKeys) []byte {
154+
if keys == nil {
155+
return nil
156+
}
157+
return keys.SharedSecret
158+
}
159+
160+
// deriveStreamMasterKey returns the key the receiver will decrypt the stream
161+
// with, given the raw FairPlay key that ekey wraps.
162+
//
163+
// A HAP-paired receiver mixes the pair-verify secret in:
164+
//
165+
// SHA-512(fairplay_decrypt(ekey) || ecdh_secret)[:16]
166+
//
167+
// A legacy receiver does not. ekey wraps the raw key, and that is the only key
168+
// material a legacy receiver ever sees, so it decrypts with that key directly.
169+
//
170+
// hapEncrypted is the discriminator, not the presence of a secret: rawPairVerify
171+
// stores a shared secret even though it deliberately leaves the channel
172+
// unencrypted, so keying off the secret alone mixed it in on the legacy path
173+
// too. The sender then encrypted with SHA-512(key || secret) while the receiver
174+
// decrypted with the raw key -- RTSP setup succeeded and the picture stayed
175+
// black. See issue #17.
176+
func deriveStreamMasterKey(rawKey, secret []byte, hapEncrypted bool) []byte {
177+
if !hapEncrypted || len(secret) == 0 {
178+
return rawKey
179+
}
180+
h := sha512.New()
181+
h.Write(rawKey)
182+
h.Write(secret)
183+
return h.Sum(nil)[:16]
184+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package airplay
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func readSource(t *testing.T, name string) []byte {
9+
t.Helper()
10+
b, err := os.ReadFile(name)
11+
if err != nil {
12+
t.Fatalf("read %s: %v", name, err)
13+
}
14+
return b
15+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package airplay
2+
3+
import (
4+
"bytes"
5+
"crypto/sha512"
6+
"testing"
7+
)
8+
9+
// The stream master key must be the raw FairPlay key on a legacy receiver and
10+
// the SHA-512 mixture on a HAP-paired one. The discriminator is whether the
11+
// channel is HAP-encrypted, not whether a shared secret happens to exist --
12+
// rawPairVerify stores one even though it leaves the channel unencrypted.
13+
func TestDeriveStreamMasterKey(t *testing.T) {
14+
raw := bytes.Repeat([]byte{0xa5}, 16)
15+
secret := bytes.Repeat([]byte{0x5a}, 32)
16+
17+
h := sha512.New()
18+
h.Write(raw)
19+
h.Write(secret)
20+
mixed := h.Sum(nil)[:16]
21+
22+
for _, tc := range []struct {
23+
name string
24+
secret []byte
25+
hapEncrypted bool
26+
want []byte
27+
}{
28+
// The case issue #17 was about: rawPairVerify leaves a shared secret
29+
// behind, but the receiver only ever saw ekey, which wraps the raw key.
30+
{"legacy pairing, secret present", secret, false, raw},
31+
{"legacy pairing, no secret", nil, false, raw},
32+
{"HAP pairing", secret, true, mixed},
33+
{"HAP flagged but no secret", nil, true, raw},
34+
} {
35+
t.Run(tc.name, func(t *testing.T) {
36+
got := deriveStreamMasterKey(raw, tc.secret, tc.hapEncrypted)
37+
if !bytes.Equal(got, tc.want) {
38+
t.Fatalf("got %x, want %x", got, tc.want)
39+
}
40+
})
41+
}
42+
}
43+
44+
// The two branches must not coincide, or the test above proves nothing.
45+
func TestDeriveStreamMasterKeyBranchesDiffer(t *testing.T) {
46+
raw := bytes.Repeat([]byte{0xa5}, 16)
47+
secret := bytes.Repeat([]byte{0x5a}, 32)
48+
if bytes.Equal(
49+
deriveStreamMasterKey(raw, secret, false),
50+
deriveStreamMasterKey(raw, secret, true),
51+
) {
52+
t.Fatal("legacy and HAP derivations produce the same key")
53+
}
54+
}
55+
56+
// rawPairVerify must keep leaving the channel unencrypted, since that flag is
57+
// what now selects the derivation. If it ever sets c.encrypted, legacy
58+
// receivers silently regress to the mixed key and the picture goes black again.
59+
func TestRawPairVerifyDoesNotEnableHAPEncryption(t *testing.T) {
60+
if !bytes.Contains(readSource(t, "pairing.go"), []byte("c.PairKeys.SharedSecret = shared")) {
61+
t.Skip("pairing.go no longer stores a shared secret in the expected form")
62+
}
63+
src := readSource(t, "pairing.go")
64+
start := bytes.Index(src, []byte("func (c *AirPlayClient) rawPairVerify"))
65+
if start < 0 {
66+
t.Skip("rawPairVerify not found")
67+
}
68+
body := src[start:]
69+
if end := bytes.Index(body, []byte("\nfunc ")); end > 0 {
70+
body = body[:end]
71+
}
72+
if bytes.Contains(body, []byte("c.encrypted = true")) {
73+
t.Error("rawPairVerify now enables HAP encryption; deriveStreamMasterKey " +
74+
"would switch legacy receivers to the mixed key (see issue #17)")
75+
}
76+
}

0 commit comments

Comments
 (0)