Summary
doubletake can pair successfully with an AppleTV3,2 (3rd gen) running AirTunes 220.68, but mirroring does not start because the FairPlay SAP response generated by the current implementation is not accepted by the receiver.
The initial observable failure is:
RTSP/1.0 466 Key Management Error
This is not a network, firewall, or pairing problem. PIN-less legacy pairing works against the receiver, and packet captures from working macOS/iOS senders establish that the FairPlay SAP m3 construction in doubletake is incomplete.
Environment
-
Receiver: AppleTV3,2
-
Receiver IP: 192.168.178.67
-
Receiver server: AirTunes/220.68
-
Sender host: Arch Linux
-
Command:
./doubletake -target 192.168.178.67 -port-range 60000-60010 -debug
-
Go version: go1.26.5 linux/amd64
Confirmed Working
-
The project builds successfully.
-
GStreamer/PipeWire/X11 dependencies are present.
-
PIN-less legacy raw pairing succeeds.
-
The receiver responds normally to RTSP requests; this is not a connection timeout.
-
Successful macOS and iOS captures show that AppleTV3,2 supports FairPlay SAP modes 0, 1, and 3.
-
The capture-derived AppleTV3 setup order is:
pairing
-> /fp-setup m1
-> /fp-setup m2
-> /fp-setup m3
-> /fp-setup m4
-> session SETUP
-> RECORD
-> compact video SETUP
Root Cause
fpsapExchangeM3 currently constructs m3 as:
fixed 144-byte prefix + computed 20-byte tail
However, successful captures show that only the first 16 bytes of m3 are fixed:
46504c59030103000000009803 8f1a9c
The following 128-byte body varies for every successful session, including multiple successful mode-3 sessions from macOS 10.13, current macOS, and iOS.
The final 20-byte tail also varies per session and is not reproduced by the current implementation for known-good captured m2 inputs.
For example, using a successful macOS 10.13 mode-3 exchange:
captured m2:
46504c59030102000000008202037ea24a071259fa83385453c327c55a330d616d760e0650618cfc029637a7ff3797464485bcccad855575828a3d102cee9aaacfb7a7d139912cb28667d6afd0f35c59afb712c137023889df5bad187305bac37b661e366f64103d2be752a896b589cf4f69432ad9e6f000ecd696cb84253848b6144b0e47df69073417cde09b6e
Apple’s successful m3 begins:
46504c590301030000000098038f1a9ca191bf7a4189e7edfb305269acc...
Current fpsapExchangeM3 instead begins:
46504c590301030000000098038f1a9c991ea22c511e45ba97f1af8dfb0f...
Both the 128-byte body and final 20-byte tail differ.
Current Code Findings
-
internal/airplay/fpsap.go hard-codes fpsapM3Prefix as 144 bytes.
-
internal/airplay/fpsap.go computes only the final 20 bytes from m2.
-
internal/airplay/fairplay_message.go originally implemented only the custom FairPlay message decrypt path.
-
A matching encrypt/inverse-CBC implementation has now been added and passes all existing modes 0 through 3 round-trip tests.
-
Decrypting valid captured mode-3 frames proves that m3 plaintext is not equal to m2 plaintext:
m2 plaintext begins: 000173c2...
m3 plaintext begins: 000149b3...
Therefore, m3 requires a separate missing 128-byte plaintext-generation step before it can be encrypted into the m3 body.
Capture Evidence
Working mode-3 senders include:
macOS 10.13.6 / AirPlay/366.76.2
macOS 15.6.1 / AirPlay/870.14.1
iOS 26.5.2 / AirPlay/950.7.1
The AppleTV3,2 accepts all of the following modes in successful captures:
Therefore the receiver does not require mode 0 specifically. The failure is the implementation of m3 generation, not mode selection alone.
Possible fix
- Replace the fixed
fpsapM3Prefix assumption with construction of:
- fixed 16-byte FPLY/header material;
- a generated 128-byte mode-specific m3 plaintext;
- custom FairPlay message encryption;
- the corresponding mode-specific 20-byte SAP tail.
- Add captured m2 -> complete m3 golden vectors for modes
0, 1, and 3.
- Once mode-3 m3 matches the known-good captures, retry against AppleTV3,2.
- Only after FairPlay succeeds, validate the AppleTV3-specific
SETUP -> RECORD -> video SETUP compatibility branch.
Edit by Sol:
The fix criteria should be expanded to require:
- explicit mode extraction and validation;
- independent decrypt and encrypt vectors, not only round trips;
- complete captured m2 → 164-byte m3 vectors;
- captured m3 + ekey → unwrapped key vectors;
- exact commit/version identification;
- an AppleTV3-specific RTSP ordering and plist-shape test;
- not yet demonstrated that the m3 plaintext-generation stage is the only missing stage.
Command outputs
2026/07/19 18:07:38 [HTTP] -> GET /info (body=0 bytes, encrypted=false, cseq=1)
2026/07/19 18:07:38 [HTTP] wrote 79 bytes to socket, waiting for response...
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 648
Content-Type: application/x-apple-binary-plist
Server: AirTunes/220.68
CSeq: 1
2026/07/19 18:07:38 [READ] status=200 content-length=648
2026/07/19 18:07:38 [READ] plaintext body: 648 bytes
2026/07/19 18:07:38 [INFO] full /info response keys: [keepAliveSendStatsAsBody vv audioLatencies keepAliveLowPower macAddress statusFlags audioFormats pk model features name sourceVersion pi deviceID]
2026/07/19 18:07:38 [INFO] audioFormats: [map[audioInputFormats:67108860 audioOutputFormats:67108860 type:100] map[audioInputFormats:67108860 audioOutputFormats:67108860 type:101]]
2026/07/19 18:07:38 [INFO] audioLatencies: [map[audioType:default inputLatencyMicros:0 outputLatencyMicros:0 type:100] map[audioType:default inputLatencyMicros:0 outputLatencyMicros:0 type:101]]
2026/07/19 18:07:38 [INFO] features: 130367356919
2026/07/19 18:07:38 [INFO] statusFlags: 68
2026/07/19 18:07:38 [INFO] keepAliveSendStatsAsBody: true
2026/07/19 18:07:38 connected to: Apple TV (model: AppleTV3,2, initialVolume: 0.0)
2026/07/19 18:07:38 [PAIR] starting transient pair-setup
2026/07/19 18:07:38 [PAIR] trying raw binary pair-setup (UxPlay-compatible)
2026/07/19 18:07:38 [HTTP] -> POST /pair-setup (body=32 bytes, encrypted=false, cseq=2)
2026/07/19 18:07:38 [HTTP] wrote 159 bytes to socket, waiting for response...
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 32
Content-Type: application/octet-stream
Server: AirTunes/220.68
CSeq: 2
2026/07/19 18:07:38 [READ] status=200 content-length=32
2026/07/19 18:07:38 [READ] plaintext body: 32 bytes
2026/07/19 18:07:38 [PAIR] raw pair-setup OK, server Ed25519 pub: 41ee7b4eb658bdbd
2026/07/19 18:07:38 [PAIR] starting raw pair-verify (no HAP encryption)
2026/07/19 18:07:38 [RAW-PV] V1: sending 68 bytes (X25519 pub + Ed25519 pub)
2026/07/19 18:07:38 [RAW-PV] V1 hex: 01000000873adf901dc36c36eb57e6a5df05925c441d4b6b50c8bdb5d7cd0696cf4b2018d73914b9d4b0badb10a99ea493ca41c17ec6307db2ea2bf5e2fd00832dd8fecc
2026/07/19 18:07:38 [RAW] -> POST /pair-verify (body=68 bytes, cseq=3)
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 96
Content-Type: application/octet-stream
Server: AirTunes/220.68
CSeq: 3
2026/07/19 18:07:38 [READ] status=200 content-length=96
2026/07/19 18:07:38 [READ] plaintext body: 96 bytes
2026/07/19 18:07:38 [RAW-PV] V2: received 96 bytes
2026/07/19 18:07:38 [RAW-PV] server signature verified OK
2026/07/19 18:07:38 [RAW-PV] V3: sending encrypted proof (68 bytes)
2026/07/19 18:07:38 [RAW] -> POST /pair-verify (body=68 bytes, cseq=4)
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 0
Content-Type: application/octet-stream
Server: AirTunes/220.68
CSeq: 4
2026/07/19 18:07:38 [READ] status=200 content-length=0
2026/07/19 18:07:38 [RAW-PV] pair-verify complete (connection stays PLAINTEXT)
2026/07/19 18:07:38 [PAIR] raw pair-verify complete (connection stays plaintext)
2026/07/19 18:07:38 pairing complete
2026/07/19 18:07:38 [FP] starting FairPlay SAP handshake...
2026/07/19 18:07:38 [FP] posting m1 (16 bytes) to /fp-setup
2026/07/19 18:07:38 [HTTP] -> POST /fp-setup (body=16 bytes, encrypted=false, cseq=5)
2026/07/19 18:07:38 [HTTP] wrote 157 bytes to socket, waiting for response...
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 142
Content-Type: application/octet-stream
Server: AirTunes/220.68
CSeq: 5
2026/07/19 18:07:38 [READ] status=200 content-length=142
2026/07/19 18:07:38 [READ] plaintext body: 142 bytes
2026/07/19 18:07:38 [FP] received m2 (142 bytes)
2026/07/19 18:07:38 [FP] m2 first 32: 46504c5903010200000000820203e15564080bd22f886a5e730238af78765e53
2026/07/19 18:07:38 [FP] m3 (164 bytes) first 32: 46504c590301030000000098038f1a9c991ea22c511e45ba97f1af8dfb0f86f5
2026/07/19 18:07:38 [FP] posting m3 (164 bytes) to /fp-setup
2026/07/19 18:07:38 [HTTP] -> POST /fp-setup (body=164 bytes, encrypted=false, cseq=6)
2026/07/19 18:07:38 [HTTP] wrote 306 bytes to socket, waiting for response...
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 200 OK
Content-Length: 32
Content-Type: application/octet-stream
Server: AirTunes/220.68
CSeq: 6
2026/07/19 18:07:38 [READ] status=200 content-length=32
2026/07/19 18:07:38 [READ] plaintext body: 32 bytes
2026/07/19 18:07:38 [FP] received m4 (32 bytes)
2026/07/19 18:07:38 [FP] m4 payload (20 bytes): 285b537aa17d619923591bc3f508bc398c270323
2026/07/19 18:07:38 [FP] ekey chunk1 [16:32]: a8716bf5a8ebd2704e9d9a2ef0ea1352
2026/07/19 18:07:38 [FP] ekey chunk2 [56:72]: c24e9d42bb7eb4ec22560ed1ed0a2e67
2026/07/19 18:07:38 [FP] unwrapFairPlayKey fpAesKey: a82e9dfd42417aa4ddc4f68005988778
2026/07/19 18:07:38 [FP] m3 first 32 bytes: 46504c590301030000000098038f1a9c991ea22c511e45ba97f1af8dfb0f86f5
2026/07/19 18:07:38 [FP] hashed with SharedSecret (32 bytes)
2026/07/19 18:07:38 [FP] FairPlay SAP handshake complete!
2026/07/19 18:07:38 [FP] fpAesKey (raw): a82e9dfd42417aa4ddc4f68005988778
2026/07/19 18:07:38 [FP] fpKey (hashed): 32c701689c4cfc71c72b8d31dea0db68
2026/07/19 18:07:38 [FP] stream IV: 555c1792573d9c578ce7ebef5f191b86
2026/07/19 18:07:38 FairPlay setup complete
2026/07/19 18:07:38 [SETUP] using encryption (key: 16 bytes, IV: 16 bytes)
2026/07/19 18:07:38 [SETUP] consecutive UDP ports: timing=60000 ctrl=60001 data=60002
2026/07/19 18:07:38 [SETUP] event listener on TCP port 60003
2026/07/19 18:07:38 [SETUP] audio encryption: AES-128-CBC (fpKey/hashed 16 bytes)
2026/07/19 18:07:38 [SETUP] FairPlay ekey=72 bytes, eiv=16 bytes, et=32
2026/07/19 18:07:38 [SETUP] phase 1 (audio+session): ct=2 spf=352 audioFormat=0x40000 controlPort=60001
2026/07/19 18:07:38 [RTSP] -> SETUP rtsp://192.168.178.67:7000/1784477258151618612 (body=674 bytes, encrypted=false, cseq=7)
2026/07/19 18:07:38 [RTSP] wrote 846 bytes to socket, waiting for response...
2026/07/19 18:07:38 [READ] reading plaintext response
2026/07/19 18:07:38 [READ] plaintext response header:
RTSP/1.0 466 Key Management Error
Content-Length: 0
Server: AirTunes/220.68
CSeq: 7
2026/07/19 18:07:38 [READ] status=466 content-length=0
2026/07/19 18:07:38 [READ] error response body (0 bytes):
2026/07/19 18:07:38 mirror setup failed: SETUP phase 1 (audio): HTTP 466 (body: )
Summary
doubletakecan pair successfully with an AppleTV3,2 (3rd gen) running AirTunes220.68, but mirroring does not start because the FairPlay SAP response generated by the current implementation is not accepted by the receiver.The initial observable failure is:
This is not a network, firewall, or pairing problem. PIN-less legacy pairing works against the receiver, and packet captures from working macOS/iOS senders establish that the FairPlay SAP m3 construction in
doubletakeis incomplete.Environment
Receiver: AppleTV3,2
Receiver IP:
192.168.178.67Receiver server:
AirTunes/220.68Sender host: Arch Linux
Command:
Go version:
go1.26.5 linux/amd64Confirmed Working
The project builds successfully.
GStreamer/PipeWire/X11 dependencies are present.
PIN-less legacy raw pairing succeeds.
The receiver responds normally to RTSP requests; this is not a connection timeout.
Successful macOS and iOS captures show that AppleTV3,2 supports FairPlay SAP modes
0,1, and3.The capture-derived AppleTV3 setup order is:
Root Cause
fpsapExchangeM3currently constructs m3 as:However, successful captures show that only the first 16 bytes of m3 are fixed:
The following 128-byte body varies for every successful session, including multiple successful mode-3 sessions from macOS 10.13, current macOS, and iOS.
The final 20-byte tail also varies per session and is not reproduced by the current implementation for known-good captured m2 inputs.
For example, using a successful macOS 10.13 mode-3 exchange:
Apple’s successful m3 begins:
Current
fpsapExchangeM3instead begins:Both the 128-byte body and final 20-byte tail differ.
Current Code Findings
internal/airplay/fpsap.gohard-codesfpsapM3Prefixas 144 bytes.internal/airplay/fpsap.gocomputes only the final 20 bytes from m2.internal/airplay/fairplay_message.gooriginally implemented only the custom FairPlay message decrypt path.A matching encrypt/inverse-CBC implementation has now been added and passes all existing modes
0through3round-trip tests.Decrypting valid captured mode-3 frames proves that m3 plaintext is not equal to m2 plaintext:
Therefore, m3 requires a separate missing 128-byte plaintext-generation step before it can be encrypted into the m3 body.
Capture Evidence
Working mode-3 senders include:
The AppleTV3,2 accepts all of the following modes in successful captures:
Therefore the receiver does not require mode 0 specifically. The failure is the implementation of m3 generation, not mode selection alone.
Possible fix
fpsapM3Prefixassumption with construction of:0,1, and3.SETUP -> RECORD -> video SETUPcompatibility branch.Edit by Sol:
The fix criteria should be expanded to require:
Command outputs