Skip to content

Commit 25a31f1

Browse files
committed
Implement ANNOUNCE (SDP) and RSA AES.
This commit has been a back-burner project for a wee while. It implements support for ANNOUNCE, and by virtue, iTunes for Windows. This was verified with iTunes v12.10.11.2. To enable this, bit flag 12 needs to be on: python3 ap2-receiver.py -m myap2 -n en0 -ftxor 12 Note: the ntp_time module does not really do anything yet. Some crypto portions do not yet work without buffered audio (flag 40), when you try e.g.: python3 ap2-receiver.py -m myap2 -n en0 -ftxor 40 12 or disable PTP: python3 ap2-receiver.py -m myap2 -n en0 -ftxor 41 40 12 This commit does not implement Apple-Challenge/Response headers.
1 parent db972ae commit 25a31f1

File tree

7 files changed

+518
-33
lines changed

7 files changed

+518
-33
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ multi-room** features. For now it implements:
1111
Ref: [here](https://emanuelecozzi.net/docs/airplay2/audio/) and
1212
[here](https://emanuelecozzi.net/docs/airplay2/rtsp/#setup)
1313
- Output latency compensation for sync with other Airplay receivers
14+
- ANNOUNCE and RSA AES for unbuffered streaming from iTunes/Windows
1415

1516
For now it does not implement:
16-
- MFi Authentication / FairPlay v2 (one of them is required by iTunes/Windows)
17-
- Audio Sync
17+
- FairPlay v2
18+
- Accurate audio sync (PTP and/or NTP)
19+
20+
It may never implement:
21+
- MFi Authentication (requires MFi hardware module)
1822

1923
**This code is experimental. This receiver do not expect to be a real receiver but a toolbox for learning/debugging all airplay protocols and related pairing/authentication methods.**
2024

ap2-receiver.py

Lines changed: 248 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
from biplist import readPlistFromString, writePlistToString
2020

2121
from ap2.connections.audio import RTPBuffer
22-
from ap2.playfair import PlayFair
22+
from ap2.playfair import PlayFair, FPAES
2323
from ap2.utils import get_volume, set_volume, set_volume_pid
2424
from ap2.pairing.hap import Hap, HAPSocket
2525
from ap2.connections.event import Event
2626
from ap2.connections.stream import Stream
2727
from ap2.dxxp import parse_dxxp
28-
from enum import IntFlag
28+
from enum import IntFlag, Enum
29+
from ap2.connections.ntp_time import NTP
2930

3031

3132
"""
@@ -216,6 +217,8 @@ def get_pub_bytes(self):
216217
HTTP_X_A_HKP = "X-Apple-HKP"
217218
HTTP_X_A_CN = "X-Apple-Client-Name"
218219
HTTP_X_A_PD = "X-Apple-PD"
220+
HTTP_X_A_AT = "X-Apple-AbsoluteTime" # Unix timestamp for current system date/time.
221+
HTTP_X_A_ET = "X-Apple-ET" # Encryption Type
219222
LTPK = LTPK()
220223

221224

@@ -342,9 +345,139 @@ def setup_global_structs(args):
342345
}
343346

344347

345-
class AP2Handler(http.server.BaseHTTPRequestHandler):
348+
class SDPHandler():
349+
# systemcrash 2021
350+
class SDPAudioFormat(Enum):
351+
(
352+
UNSUPPORTED,
353+
PCM,
354+
ALAC,
355+
AAC,
356+
AAC_ELD,
357+
OPUS,
358+
) = range(6)
359+
360+
def __init__(self, sdp=''):
361+
from ap2.connections.audio import AirplayAudFmt
362+
363+
self.sdp = sdp.splitlines()
364+
self.has_mfi = False
365+
self.has_rsa = False
366+
self.has_fp = False
367+
self.last_media = ''
368+
self.has_audio = False
369+
self.has_video = False
370+
self.audio_format = self.SDPAudioFormat.UNSUPPORTED
371+
self.minlatency = 11025
372+
self.maxlatency = 11025
373+
self.spf = 0
374+
for k in self.sdp:
375+
if 'v=' in k:
376+
self.ver_line = k
377+
elif 'o=' in k:
378+
self.o_line = k
379+
elif 's=' in k:
380+
self.subj_line = k
381+
elif 'c=' in k:
382+
self.conn_line = k
383+
elif 't=' in k:
384+
self.t_line = k
385+
elif 'm=audio' in k:
386+
self.has_audio = True
387+
self.last_media = 'audio'
388+
self.m_aud_line = k
389+
start = self.m_aud_line.find('AVP ') + 4
390+
self.audio_media_type = int(self.m_aud_line[start:])
391+
elif 'a=rtpmap:' in k and self.last_media == 'audio':
392+
self.audio_rtpmap = k.split(':')[1]
393+
start = self.audio_rtpmap.find(':') + 1
394+
mid = self.audio_rtpmap.find(' ') + 1
395+
self.payload_type = self.audio_rtpmap[start:mid - 1] # coerce to int later
396+
self.audio_encoding = self.audio_rtpmap[mid:]
397+
if self.audio_encoding == 'AppleLossless':
398+
self.audio_format = self.SDPAudioFormat.ALAC
399+
elif 'mpeg4-generic/' in self.audio_encoding:
400+
self.audio_format = self.SDPAudioFormat.AAC
401+
discard, self.audio_format_sr, self.audio_format_ch = self.audio_encoding.split('/')
402+
self.audio_format_bd = 16
403+
else:
404+
self.audio_format = self.SDPAudioFormat.PCM
405+
self.audio_format_bd, self.audio_format_sr, self.audio_format_ch = self.audio_encoding.split('/')
406+
self.audio_format_bd = ''.join(filter(str.isdigit, self.audio_format_bd))
407+
elif 'a=fmtp:' in k and self.payload_type in k:
408+
self.audio_fmtp = k.split(':')[1]
409+
self.audio_format_params = self.audio_fmtp.split(' ')
410+
if self.audio_format == self.SDPAudioFormat.ALAC:
411+
self.spf = self.audio_format_params[1] # samples per frame
412+
self.audio_format_bd = self.audio_format_params[3]
413+
self.audio_format_ch = self.audio_format_params[7]
414+
self.audio_format_sr = self.audio_format_params[11]
415+
self.audio_desc = 'ALAC'
416+
elif self.audio_format == self.SDPAudioFormat.AAC:
417+
self.audio_desc = 'AAC_LC'
418+
elif self.audio_format == self.SDPAudioFormat.PCM:
419+
self.audio_desc = 'PCM'
420+
elif self.audio_format == self.SDPAudioFormat.OPUS:
421+
self.audio_desc = 'OPUS'
422+
if 'mode=' in self.audio_fmtp:
423+
self.audio_format = self.SDPAudioFormat.AAC_ELD
424+
for x in self.audio_format_params:
425+
if 'constantDuration=' in x:
426+
start = x.find('constantDuration=') + len('constantDuration=')
427+
self.constantDuration = int(x[start:].rstrip(';'))
428+
self.spf = self.constantDuration
429+
elif 'mode=' in x:
430+
start = x.find('mode=') + len('mode=')
431+
self.aac_mode = x[start:].rstrip(';')
432+
self.audio_desc = 'AAC_ELD'
433+
for f in AirplayAudFmt:
434+
if(self.audio_desc in f.name
435+
and self.audio_format_bd in f.name
436+
and self.audio_format_sr in f.name
437+
and self.audio_format_ch in f.name
438+
):
439+
self.AirplayAudFmt = f.value
440+
self.audio_format_bd = int(self.audio_format_bd)
441+
self.audio_format_ch = int(self.audio_format_ch)
442+
self.audio_format_sr = int(self.audio_format_sr)
443+
break
444+
# video fmtp not needed, it seems.
445+
elif 'a=mfiaeskey:' in k:
446+
self.has_mfi = True
447+
self.aeskey = k.split(':')[1]
448+
elif 'a=rsaaeskey:' in k:
449+
self.has_rsa = True
450+
# RSA - Use Feat.Ft12FPSAPv2p5_AES_GCM
451+
self.aeskey = k.split(':')[1]
452+
elif 'a=fpaeskey:' in k:
453+
self.has_fp = True
454+
# FairPlay (v3?) AES key
455+
self.aeskey = k.split(':')[1]
456+
elif 'a=aesiv:' in k:
457+
self.aesiv = k.split(':')[1]
458+
elif 'a=min-latency:' in k:
459+
self.minlatency = k.split(':')[1]
460+
elif 'a=max-latency:' in k:
461+
self.maxlatency = k.split(':')[1]
462+
elif 'm=video' in k:
463+
self.has_video = True
464+
self.last_media = 'video'
465+
self.m_video_line = k
466+
start = self.m_video_line.find('AVP ') + 4
467+
self.video_media_type = int(self.m_video_line[start:])
468+
elif 'a=rtpmap:' in k and self.last_media == 'video':
469+
self.video_rtpmap = k.split(':')[1]
470+
start = self.video_rtpmap.find(':') + 1
471+
mid = self.video_rtpmap.find(' ') + 1
472+
self.video_payload = int(self.video_rtpmap[start:mid - 1])
473+
self.video_encoding = self.video_rtpmap[mid:]
346474

475+
476+
class AP2Handler(http.server.BaseHTTPRequestHandler):
477+
aeskeys = None
347478
pp = pprint.PrettyPrinter()
479+
ntp_port, ptp_port = 0, 0
480+
ntp_proc, ptp_proc = None, None
348481

349482
# Maps paths to methods a la HAP-python
350483
HANDLERS = {
@@ -432,6 +565,53 @@ def do_OPTIONS(self):
432565
)
433566
self.end_headers()
434567

568+
def do_ANNOUNCE(self):
569+
# Enable Feature bit 12: Ft12FPSAPv2p5_AES_GCM: this uses only RSA
570+
# Enabling Feat bit 25 and iTunes4win attempts AES - cannot yet decrypt.
571+
print("ANNOUNCE %s" % self.path)
572+
print(self.headers)
573+
574+
# dacp_id = self.headers.get("DACP-ID")
575+
# active_remote = self.headers.get("Active-Remote")
576+
577+
if self.headers["Content-Type"] == 'application/sdp':
578+
content_len = int(self.headers["Content-Length"])
579+
if content_len > 0:
580+
sdp_body = self.rfile.read(content_len).decode('utf-8')
581+
print(sdp_body)
582+
sdp = SDPHandler(sdp_body)
583+
if sdp.has_mfi:
584+
print("Mfi not possible on this hardware.")
585+
self.send_response(404)
586+
self.server.hap = None
587+
else:
588+
if(sdp.audio_format is SDPHandler.SDPAudioFormat.ALAC
589+
and int((FEATURES & Feat.Ft19RcvAudALAC)) == 0):
590+
print("This receiver not configured for ALAC (set flag 19).")
591+
self.send_response(404)
592+
self.server.hap = None
593+
elif (sdp.audio_format is SDPHandler.SDPAudioFormat.AAC
594+
and int((FEATURES & Feat.Ft20RcvAudAAC_LC)) == 0):
595+
print("This receiver not configured for AAC (set flag 20).")
596+
self.send_response(404)
597+
self.server.hap = None
598+
elif (sdp.audio_format is SDPHandler.SDPAudioFormat.AAC_ELD
599+
and int((FEATURES & Feat.Ft20RcvAudAAC_LC)) == 0):
600+
print("This receiver not configured for AAC (set flag 20/21).")
601+
self.send_response(404)
602+
self.server.hap = None
603+
else:
604+
if sdp.has_fp:
605+
# print('Got FP AES Key from SDP')
606+
self.aeskeys = FPAES(fpaeskey=sdp.aeskey, aesiv=sdp.aesiv)
607+
elif sdp.has_rsa:
608+
self.aeskeys = FPAES(rsaaeskey=sdp.aeskey, aesiv=sdp.aesiv)
609+
self.send_response(200)
610+
self.send_header("Server", self.version_string())
611+
self.send_header("CSeq", self.headers["CSeq"])
612+
self.end_headers()
613+
self.sdp = sdp
614+
435615
def do_FLUSHBUFFERED(self):
436616
print("FLUSHBUFFERED")
437617
self.send_response(200)
@@ -462,6 +642,53 @@ def do_SETUP(self):
462642
ua = self.headers.get("User-Agent")
463643
print("SETUP %s" % self.path)
464644
print(self.headers)
645+
# Found in SETUP after ANNOUNCE:
646+
if self.headers["Transport"]:
647+
# print(self.headers["Transport"])
648+
buff = int(self.sdp.maxlatency) # determines how many CODEC frame size 1024 we can hold
649+
650+
# Set up a stream to receive.
651+
stream = {
652+
'audioFormat': self.sdp.AirplayAudFmt,
653+
'latencyMin': int(self.sdp.minlatency),
654+
'latencyMax': int(self.sdp.maxlatency),
655+
'ct': 0, # Compression Type(?)
656+
'shk': self.aeskeys.aeskey,
657+
'shiv': self.aeskeys.aesiv,
658+
'spf': int(self.sdp.spf), # sample frames per pkt
659+
'type': int(self.sdp.payload_type),
660+
'controlPort': 0,
661+
}
662+
663+
streamobj = Stream(stream, buff)
664+
665+
self.server.streams.append(streamobj)
666+
667+
event_port, self.event_proc = Event.spawn(self.server.server_address)
668+
timing_port, self.timing_proc = NTP.spawn(self.server.server_address)
669+
transport = self.headers["Transport"].split(';')
670+
res = []
671+
res.append("RTP/AVP/UDP")
672+
res.append("unicast")
673+
res.append("mode=record")
674+
res.append("control_port=%d" % streamobj.control_port)
675+
print("control_port=%d" % streamobj.control_port)
676+
res.append("server_port=%d" % streamobj.data_port)
677+
print("server_port=%d" % streamobj.data_port)
678+
res.append("timing_port=%d" % timing_port)
679+
print("timing_port=%d" % timing_port)
680+
string = ';'
681+
682+
self.send_response(200)
683+
self.send_header("Transport", string.join(res))
684+
self.send_header("Session", "1")
685+
self.send_header("Audio-Jack-Status", 'connected; type=analog')
686+
self.send_header("Server", self.version_string())
687+
self.send_header("CSeq", self.headers["CSeq"])
688+
self.end_headers()
689+
690+
return
691+
465692
if self.headers["Content-Type"] == HTTP_CT_BPLIST:
466693
content_len = int(self.headers["Content-Length"])
467694
if content_len > 0:
@@ -632,10 +859,6 @@ def do_TEARDOWN(self):
632859
stream = self.server.streams[stream_id]
633860
stream.teardown()
634861
del self.server.streams[stream_id]
635-
else:
636-
for stream in self.server.streams:
637-
stream.teardown()
638-
self.server.streams.clear()
639862
self.pp.pprint(plist)
640863
self.send_response(200)
641864
self.send_header("Server", self.version_string())
@@ -647,8 +870,23 @@ def do_TEARDOWN(self):
647870

648871
# terminate the forked event_proc, otherwise a zombie process consumes 100% cpu
649872
self.event_proc.terminate()
873+
if(self.ntp_proc):
874+
self.ntp_proc.terminate()
875+
# When changing from RTP_BUFFERED to REALTIME, must clean up:
876+
for stream in self.server.streams:
877+
stream.teardown()
878+
self.server.streams.clear()
650879

651880
def do_SETPEERS(self):
881+
"""
882+
A shorter format to set timing (PTP clock) peers.
883+
884+
Content-Type: /peer-list-changed
885+
Contains [] array of IP{4|6}addrs:
886+
['...::...',
887+
'...::...',
888+
'...']
889+
"""
652890
print("SETPEERS %s" % self.path)
653891
print(self.headers)
654892
content_len = int(self.headers["Content-Length"])
@@ -663,14 +901,14 @@ def do_SETPEERS(self):
663901
self.end_headers()
664902

665903
def do_SETPEERSX(self):
666-
# extended message format for setting PTP clock peers
904+
# Extended format for setting timing (PTP clock) peers
667905
# Requires Ft52PeersExtMsg (bit 52)
668906
# Note: this method does not require defining in do_OPTIONS
669907

670908
# Content-Type: /peer-list-changed-x
671909
# Contains [] array of:
672-
# {'Addresses': ['fe80::fb:97fb:2fb3:34bc',
673-
# '192.168.19.110'],
910+
# {'Addresses': ['fe80::...',
911+
# '...'],
674912
# 'ClockID': 000000000000000000,
675913
# 'ClockPorts': {GUID1: port,
676914
# GUID2: port,

0 commit comments

Comments
 (0)