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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ AirPlay screen mirroring sender for Linux. Streams your desktop to an Apple TV u
## Features

- Full AirPlay 2 mirroring protocol (RTSP/HTTP + encrypted video stream)
- FairPlay SAP authentication (snapshot-backed Go ARM64 execution)
- FairPlay SAP authentication (clean Go implementation)
- SRP-6a pairing with PIN and persistent credential storage
- Wayland (PipeWire/xdg-desktop-portal) and X11 screen capture
- Hardware-accelerated H.264 encoding (NVENC, VA-API) with software fallback
Expand Down
12 changes: 4 additions & 8 deletions internal/airplay/capture_m2_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
//go:build !emulate

package airplay

import (
Expand Down Expand Up @@ -73,16 +71,14 @@ func TestCaptureM2(t *testing.T) {

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

// Save as Go file in fpemu package
goSrc := fmt.Sprintf(`//go:build !emulate

package fpemu
// Save as a Go fixture beside the clean FPSAP implementation.
goSrc := fmt.Sprintf(`package airplay

import "encoding/hex"

// capturedM2 is a real m2 response captured from an Apple TV.
// Generated by TestCaptureM2 in internal/airplay/.
var capturedM2 = mustDecodeHexM2("%s")
var capturedFPSAPM2 = mustDecodeHexM2("%s")

func mustDecodeHexM2(s string) []byte {
b, err := hex.DecodeString(s)
Expand All @@ -93,7 +89,7 @@ func mustDecodeHexM2(s string) []byte {
}
`, hex.EncodeToString(m2))

outPath := "../fpemu/captured_m2_test.go"
outPath := "fpsap_captured_m2_test.go"
if err := os.WriteFile(outPath, []byte(goSrc), 0644); err != nil {
t.Fatalf("write %s: %v", outPath, err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/airplay/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ type AirPlayClient struct {
fpIV []byte
FpEkey []byte // 72-byte wrapped key for SETUP
fpM3 []byte // 164-byte FPLY-wrapped m3 (needed for ekey construction)
fpAesKey []byte // 16-byte raw aesKey from playfair_decrypt (IKM for HKDF)
fpAesKey []byte // 16-byte raw aesKey from FairPlay key unwrap (IKM for HKDF)

// Stream encryption key (from FP or pair-verify)
streamKey []byte
Expand Down
45 changes: 8 additions & 37 deletions internal/airplay/fairplay.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ import (
"encoding/hex"
"errors"
"fmt"

"doubletake/internal/fpemu"
)

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

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

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

// Phase 2: Compute m3 via standalone interpreter, send to server
m3raw, err := fpemu.FPSAPExchangeM3(m2)
// Phase 2: Compute m3 and send it to the receiver.
m3, err := fpsapExchangeM3(m2)
if err != nil {
return fmt.Errorf("FPSAPExchange: %w", err)
}

// Ensure FPLY framing
m3 := m3raw
if len(m3) < 4 || string(m3[:4]) != "FPLY" {
m3 = fplyWrap(m3raw, 0x03)
}

dbg("[FP] m3 (%d bytes) first 32: %02x", len(m3), m3[:min(32, len(m3))])

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

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

fpAesKey := playfairDecrypt(c.fpM3, ekey[:])
fpAesKey := unwrapFairPlayKey(c.fpM3, ekey[:])
c.fpAesKey = fpAesKey[:]
dbg("[FP] playfairDecrypt fpAesKey: %02x", fpAesKey[:])
dbg("[FP] unwrapFairPlayKey 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.
Expand All @@ -135,7 +126,7 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
}

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

// fplyWrap adds FPLY framing header to raw SAP data.
// If the data already starts with "FPLY", it's returned as-is.
func fplyWrap(data []byte, msgType byte) []byte {
if len(data) >= 4 && string(data[:4]) == "FPLY" {
return data
}
header := make([]byte, 12+len(data))
copy(header[0:4], []byte("FPLY"))
header[4] = 0x03
header[5] = 0x01
header[6] = msgType
header[7] = 0x00
header[8] = byte(len(data) >> 24)
header[9] = byte(len(data) >> 16)
header[10] = byte(len(data) >> 8)
header[11] = byte(len(data))
copy(header[12:], data)
return header
}

// fplyUnwrap strips the FPLY framing header and returns the payload.
// If the data doesn't have FPLY framing, it's returned as-is.
func fplyUnwrap(data []byte) []byte {
Expand Down
75 changes: 75 additions & 0 deletions internal/airplay/fairplay_crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package airplay

import (
"crypto/aes"
"encoding/binary"
)

var fairplayInitialSessionKey = [16]byte{
0xdc, 0xdc, 0xf3, 0xb9, 0x0b, 0x74, 0xdc, 0xfb,
0x86, 0x7f, 0xf7, 0x60, 0x16, 0x72, 0x90, 0x51,
}

var fairplayKDFPrefix = [17]byte{
0xfa, 0x9c, 0xad, 0x4d, 0x4b, 0x68, 0x26, 0x8c,
0x7f, 0xf3, 0x88, 0x99, 0xde, 0x92, 0x2e, 0x95, 0x1e,
}

var fairplayKDFSuffix = [17]byte{
0xec, 0x4e, 0x27, 0x5e, 0xfd, 0xf2, 0xe8, 0x30,
0x97, 0xae, 0x70, 0xfb, 0xe0, 0x00, 0x3f, 0x1c, 0x39,
}

// Only the second 128 bytes of the fixed SAP record participate in the KDF.
var fairplayDefaultSAPTail = [128]byte{
0x00, 0x01, 0xcc, 0x34, 0x2a, 0x5e, 0x5b, 0x1a, 0x67, 0x73, 0xc2, 0x0e, 0x21, 0xb8, 0x22, 0x4d,
0xf8, 0x62, 0x48, 0x18, 0x64, 0xef, 0x81, 0x0a, 0xae, 0x2e, 0x37, 0x03, 0xc8, 0x81, 0x9c, 0x23,
0x53, 0x9d, 0xe5, 0xf5, 0xd7, 0x49, 0xbc, 0x5b, 0x7a, 0x26, 0x6c, 0x49, 0x62, 0x83, 0xce, 0x7f,
0x03, 0x93, 0x7a, 0xe1, 0xf6, 0x16, 0xde, 0x0c, 0x15, 0xff, 0x33, 0x8c, 0xca, 0xff, 0xb0, 0x9e,
0xaa, 0xbb, 0xe4, 0x0f, 0x5d, 0x5f, 0x55, 0x8f, 0xb9, 0x7f, 0x17, 0x31, 0xf8, 0xf7, 0xda, 0x60,
0xa0, 0xec, 0x65, 0x79, 0xc3, 0x3e, 0xa9, 0x83, 0x12, 0xc3, 0xb6, 0x71, 0x35, 0xa6, 0x69, 0x4f,
0xf8, 0x23, 0x05, 0xd9, 0xba, 0x5c, 0x61, 0x5f, 0xa2, 0x54, 0xd2, 0xb1, 0x83, 0x45, 0x83, 0xce,
0xe4, 0x2d, 0x44, 0x26, 0xc8, 0x35, 0xa7, 0xa5, 0xf6, 0xc8, 0x42, 0x1c, 0x0d, 0xa3, 0xf1, 0xc7,
}

func deriveFairPlayWrappingKey(sapTail []byte, message []byte) [16]byte {
var decrypted [128]byte
decryptFairPlayMessage(message, decrypted[:])

// The KDF input is a 290-byte protocol record followed by ordinary MD5
// padding. The compression itself is FairPlay's modified MD5/SAP-hash
// combination, not a standard MD5 digest.
var material [320]byte
offset := copy(material[:], fairplayKDFPrefix[:])
offset += copy(material[offset:], decrypted[:])
offset += copy(material[offset:], sapTail[:128])
offset += copy(material[offset:], fairplayKDFSuffix[:])
material[offset] = 0x80
binary.LittleEndian.PutUint64(material[len(material)-8:], uint64(offset)*8)

state := fairplayWordsFromLittleEndian(fairplayInitialSessionKey)
for offset := 0; offset < len(material); offset += 64 {
block := material[offset : offset+64]
modified := fairplayMD5Compress(state, block, fairplayKDFMutation)
hashed := fairplaySAPHash(block)
for word := range state {
state[word] = modified[word] + binary.LittleEndian.Uint32(hashed[word*4:])
}
}
return fairplayWordsBigEndian(state)
}

func unwrapFairPlayKey(m3 []byte, ekey []byte) [16]byte {
aesKey := deriveFairPlayWrappingKey(fairplayDefaultSAPTail[:], m3)
cipher, err := aes.NewCipher(aesKey[:])
if err != nil {
panic(err) // aesKey always has the fixed AES-128 length.
}

var keyOut [16]byte
cipher.Decrypt(keyOut[:], ekey[56:72])
for i := range keyOut {
keyOut[i] ^= ekey[16+i]
}
return keyOut
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import (
"testing"
)

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

ekey := buildEkey()

key1 := playfairDecrypt(m3, ekey[:])
key2 := playfairDecrypt(m3, ekey[:])
key1 := unwrapFairPlayKey(m3, ekey[:])
key2 := unwrapFairPlayKey(m3, ekey[:])

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

Expand All @@ -39,14 +39,14 @@ func TestPlayfairDecryptDeterministic(t *testing.T) {
}
}
if allZero {
t.Fatal("playfairDecrypt produced all-zero key")
t.Fatal("unwrapFairPlayKey produced all-zero key")
}

t.Logf("playfairDecrypt key: %s", hex.EncodeToString(key1[:]))
t.Logf("unwrapFairPlayKey key: %s", hex.EncodeToString(key1[:]))
}

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

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

key := playfairDecrypt(m3, ekey[:])
key := unwrapFairPlayKey(m3, ekey[:])
hexKey := hex.EncodeToString(key[:])
t.Logf("mode=%d key=%s", mode, hexKey)

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

Loading
Loading