Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions internal/airplay/fairplay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
15 changes: 15 additions & 0 deletions internal/airplay/fairplay_streamkey_helper_test.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions internal/airplay/fairplay_streamkey_test.go
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading