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)") + } +}