Skip to content

Commit 9eb5055

Browse files
authored
Add whitebox implementation for FairPlay (#14)
* Rewrite fairplay
1 parent 364ea84 commit 9eb5055

22 files changed

Lines changed: 1309 additions & 14833 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ AirPlay screen mirroring sender for Linux. Streams your desktop to an Apple TV u
55
## Features
66

77
- Full AirPlay 2 mirroring protocol (RTSP/HTTP + encrypted video stream)
8-
- FairPlay SAP authentication (snapshot-backed Go ARM64 execution)
8+
- FairPlay SAP authentication (clean Go implementation)
99
- SRP-6a pairing with PIN and persistent credential storage
1010
- Wayland (PipeWire/xdg-desktop-portal) and X11 screen capture
1111
- Hardware-accelerated H.264 encoding (NVENC, VA-API) with software fallback

internal/airplay/capture_m2_test.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
//go:build !emulate
2-
31
package airplay
42

53
import (
@@ -73,16 +71,14 @@ func TestCaptureM2(t *testing.T) {
7371

7472
t.Logf("m2 (%d bytes): %s", len(m2), hex.EncodeToString(m2))
7573

76-
// Save as Go file in fpemu package
77-
goSrc := fmt.Sprintf(`//go:build !emulate
78-
79-
package fpemu
74+
// Save as a Go fixture beside the clean FPSAP implementation.
75+
goSrc := fmt.Sprintf(`package airplay
8076
8177
import "encoding/hex"
8278
8379
// capturedM2 is a real m2 response captured from an Apple TV.
8480
// Generated by TestCaptureM2 in internal/airplay/.
85-
var capturedM2 = mustDecodeHexM2("%s")
81+
var capturedFPSAPM2 = mustDecodeHexM2("%s")
8682
8783
func mustDecodeHexM2(s string) []byte {
8884
b, err := hex.DecodeString(s)
@@ -93,7 +89,7 @@ func mustDecodeHexM2(s string) []byte {
9389
}
9490
`, hex.EncodeToString(m2))
9591

96-
outPath := "../fpemu/captured_m2_test.go"
92+
outPath := "fpsap_captured_m2_test.go"
9793
if err := os.WriteFile(outPath, []byte(goSrc), 0644); err != nil {
9894
t.Fatalf("write %s: %v", outPath, err)
9995
}

internal/airplay/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ type AirPlayClient struct {
105105
fpIV []byte
106106
FpEkey []byte // 72-byte wrapped key for SETUP
107107
fpM3 []byte // 164-byte FPLY-wrapped m3 (needed for ekey construction)
108-
fpAesKey []byte // 16-byte raw aesKey from playfair_decrypt (IKM for HKDF)
108+
fpAesKey []byte // 16-byte raw aesKey from FairPlay key unwrap (IKM for HKDF)
109109

110110
// Stream encryption key (from FP or pair-verify)
111111
streamKey []byte

internal/airplay/fairplay.go

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ import (
77
"encoding/hex"
88
"errors"
99
"fmt"
10-
11-
"doubletake/internal/fpemu"
1210
)
1311

14-
// fairPlayM1 is the fixed m1 blob that matches the snapshot state.
12+
// fairPlayM1 is the fixed first message in the FairPlay SAP exchange.
1513
var fairPlayM1 = mustDecodeHexFP("46504c590301010000000004020003bb")
1614

1715
var ErrFairPlayUnsupported = errors.New("receiver does not support FairPlay SAP")
@@ -24,8 +22,7 @@ func mustDecodeHexFP(s string) []byte {
2422
return b
2523
}
2624

27-
// FairPlaySetup performs the complete FairPlay SAP handshake using the
28-
// standalone ARM64 interpreter.
25+
// FairPlaySetup performs the complete FairPlay SAP handshake.
2926
func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
3027
if c.info != nil && !c.info.SupportsFairPlaySAP() {
3128
return fmt.Errorf("%w: FPSAP feature bit is not advertised (features=0x%x)", ErrFairPlayUnsupported, c.info.Features)
@@ -54,18 +51,12 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
5451
dbg("[FP] received m2 (%d bytes)", len(m2))
5552
dbg("[FP] m2 first 32: %02x", m2[:min(32, len(m2))])
5653

57-
// Phase 2: Compute m3 via standalone interpreter, send to server
58-
m3raw, err := fpemu.FPSAPExchangeM3(m2)
54+
// Phase 2: Compute m3 and send it to the receiver.
55+
m3, err := fpsapExchangeM3(m2)
5956
if err != nil {
6057
return fmt.Errorf("FPSAPExchange: %w", err)
6158
}
6259

63-
// Ensure FPLY framing
64-
m3 := m3raw
65-
if len(m3) < 4 || string(m3[:4]) != "FPLY" {
66-
m3 = fplyWrap(m3raw, 0x03)
67-
}
68-
6960
dbg("[FP] m3 (%d bytes) first 32: %02x", len(m3), m3[:min(32, len(m3))])
7061

7162
dbg("[FP] posting m3 (%d bytes) to /fp-setup", len(m3))
@@ -99,16 +90,16 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
9990
copy(c.fpM3, m3)
10091

10192
// Build ekey and derive audio encryption key.
102-
// Both sender and receiver call playfairDecrypt(m3, ekey) with the same
93+
// Both sender and receiver call unwrapFairPlayKey(m3, ekey) with the same
10394
// inputs (m3 sent during FP handshake, ekey sent in SETUP body).
10495
ekey := buildEkey()
10596
c.FpEkey = ekey[:]
10697
dbg("[FP] ekey chunk1 [16:32]: %02x", ekey[16:32])
10798
dbg("[FP] ekey chunk2 [56:72]: %02x", ekey[56:72])
10899

109-
fpAesKey := playfairDecrypt(c.fpM3, ekey[:])
100+
fpAesKey := unwrapFairPlayKey(c.fpM3, ekey[:])
110101
c.fpAesKey = fpAesKey[:]
111-
dbg("[FP] playfairDecrypt fpAesKey: %02x", fpAesKey[:])
102+
dbg("[FP] unwrapFairPlayKey fpAesKey: %02x", fpAesKey[:])
112103
dbg("[FP] m3 first 32 bytes: %02x", c.fpM3[:min(32, len(c.fpM3))])
113104

114105
// Hash with pair-verify shared secret (ECDH X25519) if available.
@@ -135,7 +126,7 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
135126
}
136127

137128
// buildEkey constructs a 72-byte ekey with the FPLY header format.
138-
// The chunk data is randomized per session so that playfairDecrypt produces
129+
// The chunk data is randomized per session so that unwrapFairPlayKey produces
139130
// a unique AES key for each session. Both sender and receiver compute the
140131
// same key from the same (m3, ekey) inputs.
141132
//
@@ -165,26 +156,6 @@ func buildEkey() [72]byte {
165156
return ekey
166157
}
167158

168-
// fplyWrap adds FPLY framing header to raw SAP data.
169-
// If the data already starts with "FPLY", it's returned as-is.
170-
func fplyWrap(data []byte, msgType byte) []byte {
171-
if len(data) >= 4 && string(data[:4]) == "FPLY" {
172-
return data
173-
}
174-
header := make([]byte, 12+len(data))
175-
copy(header[0:4], []byte("FPLY"))
176-
header[4] = 0x03
177-
header[5] = 0x01
178-
header[6] = msgType
179-
header[7] = 0x00
180-
header[8] = byte(len(data) >> 24)
181-
header[9] = byte(len(data) >> 16)
182-
header[10] = byte(len(data) >> 8)
183-
header[11] = byte(len(data))
184-
copy(header[12:], data)
185-
return header
186-
}
187-
188159
// fplyUnwrap strips the FPLY framing header and returns the payload.
189160
// If the data doesn't have FPLY framing, it's returned as-is.
190161
func fplyUnwrap(data []byte) []byte {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package airplay
2+
3+
import (
4+
"crypto/aes"
5+
"encoding/binary"
6+
)
7+
8+
var fairplayInitialSessionKey = [16]byte{
9+
0xdc, 0xdc, 0xf3, 0xb9, 0x0b, 0x74, 0xdc, 0xfb,
10+
0x86, 0x7f, 0xf7, 0x60, 0x16, 0x72, 0x90, 0x51,
11+
}
12+
13+
var fairplayKDFPrefix = [17]byte{
14+
0xfa, 0x9c, 0xad, 0x4d, 0x4b, 0x68, 0x26, 0x8c,
15+
0x7f, 0xf3, 0x88, 0x99, 0xde, 0x92, 0x2e, 0x95, 0x1e,
16+
}
17+
18+
var fairplayKDFSuffix = [17]byte{
19+
0xec, 0x4e, 0x27, 0x5e, 0xfd, 0xf2, 0xe8, 0x30,
20+
0x97, 0xae, 0x70, 0xfb, 0xe0, 0x00, 0x3f, 0x1c, 0x39,
21+
}
22+
23+
// Only the second 128 bytes of the fixed SAP record participate in the KDF.
24+
var fairplayDefaultSAPTail = [128]byte{
25+
0x00, 0x01, 0xcc, 0x34, 0x2a, 0x5e, 0x5b, 0x1a, 0x67, 0x73, 0xc2, 0x0e, 0x21, 0xb8, 0x22, 0x4d,
26+
0xf8, 0x62, 0x48, 0x18, 0x64, 0xef, 0x81, 0x0a, 0xae, 0x2e, 0x37, 0x03, 0xc8, 0x81, 0x9c, 0x23,
27+
0x53, 0x9d, 0xe5, 0xf5, 0xd7, 0x49, 0xbc, 0x5b, 0x7a, 0x26, 0x6c, 0x49, 0x62, 0x83, 0xce, 0x7f,
28+
0x03, 0x93, 0x7a, 0xe1, 0xf6, 0x16, 0xde, 0x0c, 0x15, 0xff, 0x33, 0x8c, 0xca, 0xff, 0xb0, 0x9e,
29+
0xaa, 0xbb, 0xe4, 0x0f, 0x5d, 0x5f, 0x55, 0x8f, 0xb9, 0x7f, 0x17, 0x31, 0xf8, 0xf7, 0xda, 0x60,
30+
0xa0, 0xec, 0x65, 0x79, 0xc3, 0x3e, 0xa9, 0x83, 0x12, 0xc3, 0xb6, 0x71, 0x35, 0xa6, 0x69, 0x4f,
31+
0xf8, 0x23, 0x05, 0xd9, 0xba, 0x5c, 0x61, 0x5f, 0xa2, 0x54, 0xd2, 0xb1, 0x83, 0x45, 0x83, 0xce,
32+
0xe4, 0x2d, 0x44, 0x26, 0xc8, 0x35, 0xa7, 0xa5, 0xf6, 0xc8, 0x42, 0x1c, 0x0d, 0xa3, 0xf1, 0xc7,
33+
}
34+
35+
func deriveFairPlayWrappingKey(sapTail []byte, message []byte) [16]byte {
36+
var decrypted [128]byte
37+
decryptFairPlayMessage(message, decrypted[:])
38+
39+
// The KDF input is a 290-byte protocol record followed by ordinary MD5
40+
// padding. The compression itself is FairPlay's modified MD5/SAP-hash
41+
// combination, not a standard MD5 digest.
42+
var material [320]byte
43+
offset := copy(material[:], fairplayKDFPrefix[:])
44+
offset += copy(material[offset:], decrypted[:])
45+
offset += copy(material[offset:], sapTail[:128])
46+
offset += copy(material[offset:], fairplayKDFSuffix[:])
47+
material[offset] = 0x80
48+
binary.LittleEndian.PutUint64(material[len(material)-8:], uint64(offset)*8)
49+
50+
state := fairplayWordsFromLittleEndian(fairplayInitialSessionKey)
51+
for offset := 0; offset < len(material); offset += 64 {
52+
block := material[offset : offset+64]
53+
modified := fairplayMD5Compress(state, block, fairplayKDFMutation)
54+
hashed := fairplaySAPHash(block)
55+
for word := range state {
56+
state[word] = modified[word] + binary.LittleEndian.Uint32(hashed[word*4:])
57+
}
58+
}
59+
return fairplayWordsBigEndian(state)
60+
}
61+
62+
func unwrapFairPlayKey(m3 []byte, ekey []byte) [16]byte {
63+
aesKey := deriveFairPlayWrappingKey(fairplayDefaultSAPTail[:], m3)
64+
cipher, err := aes.NewCipher(aesKey[:])
65+
if err != nil {
66+
panic(err) // aesKey always has the fixed AES-128 length.
67+
}
68+
69+
var keyOut [16]byte
70+
cipher.Decrypt(keyOut[:], ekey[56:72])
71+
for i := range keyOut {
72+
keyOut[i] ^= ekey[16+i]
73+
}
74+
return keyOut
75+
}
Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import (
55
"testing"
66
)
77

8-
// TestPlayfairDecryptDeterministic verifies that playfairDecrypt produces a
8+
// TestFairPlayKeyUnwrapDeterministic verifies that unwrapFairPlayKey produces a
99
// consistent, non-zero key from a known m3 and ekey.
10-
func TestPlayfairDecryptDeterministic(t *testing.T) {
10+
func TestFairPlayKeyUnwrapDeterministic(t *testing.T) {
1111
// Build a fixed m3 with mode=3 and deterministic payload.
1212
m3 := make([]byte, 164)
1313
copy(m3[0:4], []byte("FPLY"))
@@ -22,11 +22,11 @@ func TestPlayfairDecryptDeterministic(t *testing.T) {
2222

2323
ekey := buildEkey()
2424

25-
key1 := playfairDecrypt(m3, ekey[:])
26-
key2 := playfairDecrypt(m3, ekey[:])
25+
key1 := unwrapFairPlayKey(m3, ekey[:])
26+
key2 := unwrapFairPlayKey(m3, ekey[:])
2727

2828
if key1 != key2 {
29-
t.Fatalf("playfairDecrypt is not deterministic:\n key1=%s\n key2=%s",
29+
t.Fatalf("unwrapFairPlayKey is not deterministic:\n key1=%s\n key2=%s",
3030
hex.EncodeToString(key1[:]), hex.EncodeToString(key2[:]))
3131
}
3232

@@ -39,14 +39,14 @@ func TestPlayfairDecryptDeterministic(t *testing.T) {
3939
}
4040
}
4141
if allZero {
42-
t.Fatal("playfairDecrypt produced all-zero key")
42+
t.Fatal("unwrapFairPlayKey produced all-zero key")
4343
}
4444

45-
t.Logf("playfairDecrypt key: %s", hex.EncodeToString(key1[:]))
45+
t.Logf("unwrapFairPlayKey key: %s", hex.EncodeToString(key1[:]))
4646
}
4747

48-
// TestPlayfairDecryptModes verifies all 4 valid mode bytes produce distinct keys.
49-
func TestPlayfairDecryptModes(t *testing.T) {
48+
// TestFairPlayKeyUnwrapModes verifies all 4 valid mode bytes produce distinct keys.
49+
func TestFairPlayKeyUnwrapModes(t *testing.T) {
5050
ekey := buildEkey()
5151
keys := make(map[string]byte)
5252

@@ -63,7 +63,7 @@ func TestPlayfairDecryptModes(t *testing.T) {
6363
m3[i] = byte(i)
6464
}
6565

66-
key := playfairDecrypt(m3, ekey[:])
66+
key := unwrapFairPlayKey(m3, ekey[:])
6767
hexKey := hex.EncodeToString(key[:])
6868
t.Logf("mode=%d key=%s", mode, hexKey)
6969

@@ -73,4 +73,3 @@ func TestPlayfairDecryptModes(t *testing.T) {
7373
keys[hexKey] = mode
7474
}
7575
}
76-

0 commit comments

Comments
 (0)