/
/
/
1"""
2Pairing implementations for AirPlay devices.
3
4This module provides pairing support for:
5- AirPlay 2 (HAP - HomeKit Accessory Protocol) - for Apple TV 4+, HomePod, Mac.
6 Delegated to the cliairplay binary (--pair-setup): the same HAP implementation
7 performs pair-verify at stream time, so credentials and DACP identity always match.
8- RAOP (AirPlay 1 legacy pairing) - for older devices, implemented natively.
9
10Both produce credentials compatible with cliairplay.
11"""
12
13from __future__ import annotations
14
15import asyncio
16import hashlib
17import logging
18import os
19import plistlib
20
21import aiohttp
22from cryptography.hazmat.primitives import serialization
23from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
24from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
25from music_assistant_models.errors import PlayerCommandFailed
26
27from music_assistant.helpers.process import AsyncProcess
28from music_assistant.helpers.util import format_ip_for_url
29
30from .constants import StreamingProtocol
31from .helpers import get_cli_binary
32
33# Timeout for the binary to complete the SRP exchange after the PIN is entered
34PAIR_SETUP_TIMEOUT = 60
35
36# ============================================================================
37# RAOP Pairing constants (for AirPlay 1 legacy)
38# ============================================================================
39
40# SRP 2048-bit prime for RAOP (hex string format)
41RAOP_SRP_PRIME_2048 = (
42 "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC319294"
43 "3DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310D"
44 "CD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FB"
45 "D5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF74"
46 "7359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A"
47 "436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D"
48 "5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E73"
49 "03CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6"
50 "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F"
51 "9E4AFF73"
52)
53RAOP_SRP_GENERATOR = "02" # RFC5054-2048bit uses generator 2
54
55
56class AirPlayPairing:
57 """
58 Pairing session for an AirPlay device.
59
60 Handles both HAP (AirPlay 2, via the cliairplay binary) and RAOP
61 (AirPlay 1, native) pairing protocols.
62 """
63
64 def __init__(
65 self,
66 address: str,
67 name: str,
68 protocol: StreamingProtocol,
69 logger: logging.Logger,
70 port: int | None = None,
71 device_id: str | None = None,
72 ) -> None:
73 """
74 Initialize AirPlay pairing.
75
76 :param address: IP address of the device.
77 :param name: Display name of the device.
78 :param protocol: Streaming protocol (RAOP or AIRPLAY2).
79 :param logger: Logger instance.
80 :param port: Port number (default: 7000 for AirPlay 2, 5000 for RAOP).
81 :param device_id: Device identifier (DACP ID) - must match what cliairplay
82 uses at stream time (pair-verify signs with it).
83 """
84 self.address = address
85 self.name = name
86 self.protocol = protocol
87 self.logger = logger
88 self.port = port or (7000 if protocol == StreamingProtocol.AIRPLAY2 else 5000)
89 self.device_id = device_id
90
91 # cliairplay --pair-setup subprocess state (AirPlay 2)
92 self._cli_binary: str | None = None
93 self._pair_proc: AsyncProcess | None = None
94 self._pair_proc_stderr: list[str] = []
95
96 # HTTP session (RAOP)
97 self._session: aiohttp.ClientSession | None = None
98 self._base_url: str = f"http://{format_ip_for_url(address)}:{self.port}"
99
100 # RAOP client identifier: 8 random bytes, the credentials are self-contained
101 self._client_id = os.urandom(8)
102
103 @property
104 def protocol_name(self) -> str:
105 """Return human-readable protocol name."""
106 if self.protocol == StreamingProtocol.RAOP:
107 return "RAOP (AirPlay 1)"
108 return "AirPlay"
109
110 async def start_pairing_session(self) -> None:
111 """Prepare a new pairing session."""
112 self.logger.info(
113 "Starting %s pairing with %s at %s:%d",
114 self.protocol_name,
115 self.name,
116 self.address,
117 self.port,
118 )
119 if self.protocol == StreamingProtocol.AIRPLAY2:
120 self._cli_binary = await get_cli_binary()
121 else:
122 self._session = aiohttp.ClientSession()
123
124 async def start_pin_pairing(self) -> bool:
125 """
126 Start the pairing process, making the device display its PIN.
127
128 :return: True if device provides PIN.
129 :raises PlayerCommandFailed: If device connection fails.
130 """
131 if self.protocol == StreamingProtocol.AIRPLAY2:
132 # the binary POSTs /pair-pin-start right after connecting,
133 # then waits for the PIN on its stdin
134 await self._start_pair_setup_process()
135 self.logger.info("Device %s should now display its PIN", self.name)
136 return True
137
138 if not self._session:
139 raise PlayerCommandFailed("Session not started")
140 try:
141 # Request PIN to be shown on device
142 async with self._session.post(
143 f"{self._base_url}/pair-pin-start",
144 timeout=aiohttp.ClientTimeout(total=10),
145 ) as resp:
146 if resp.status != 200:
147 raise PlayerCommandFailed(f"Failed to start pairing: HTTP {resp.status}")
148
149 self.logger.info("Device %s is displaying PIN", self.name)
150 return True
151
152 except aiohttp.ClientError as err:
153 await self.close()
154 raise PlayerCommandFailed(f"Connection failed: {err}") from err
155
156 async def finish_pairing(self, pin: str) -> str:
157 """
158 Complete pairing with the provided PIN or password.
159
160 :param pin: 4-digit PIN from device screen or device password.
161 :return: Credentials string for cliairplay.
162 :raises PlayerCommandFailed: If pairing fails.
163 """
164 try:
165 if self.protocol == StreamingProtocol.AIRPLAY2:
166 return await self._finish_cli_pair_setup(pin)
167 if not self._session:
168 raise PlayerCommandFailed("Pairing not started")
169 return await self._finish_raop_pairing(pin)
170 except PlayerCommandFailed:
171 raise
172 except Exception as err:
173 self.logger.exception("Pairing failed")
174 raise PlayerCommandFailed(f"Pairing failed: {err}") from err
175 finally:
176 await self.close()
177
178 async def close(self) -> None:
179 """Clean up resources."""
180 if self._pair_proc and not self._pair_proc.closed:
181 await self._pair_proc.kill()
182 self._pair_proc = None
183 if self._session:
184 await self._session.close()
185 self._session = None
186
187 # ========================================================================
188 # HAP (AirPlay 2) pairing via cliairplay --pair-setup
189 # ========================================================================
190
191 async def _start_pair_setup_process(self) -> None:
192 """Spawn the cliairplay --pair-setup process (device shows its PIN)."""
193 if self._pair_proc and not self._pair_proc.closed:
194 return
195 if not self._cli_binary:
196 raise PlayerCommandFailed("Pairing not started")
197 if not self.device_id:
198 raise PlayerCommandFailed("Pairing requires a DACP id")
199 args = [
200 self._cli_binary,
201 "--pair-setup",
202 "--port",
203 str(self.port),
204 "--dacp",
205 self.device_id,
206 self.address,
207 ]
208 self._pair_proc_stderr = []
209 self._pair_proc = AsyncProcess(
210 args, stdin=True, stdout=True, stderr=True, name="cliairplay-pair-setup"
211 )
212 await self._pair_proc.start()
213 self._pair_proc.attach_stderr_reader(
214 asyncio.create_task(self._pair_setup_stderr_reader(self._pair_proc))
215 )
216
217 async def _pair_setup_stderr_reader(self, proc: AsyncProcess) -> None:
218 """Collect (and debug-log) stderr output of the pair-setup process."""
219 async for line in proc.iter_stderr():
220 self._pair_proc_stderr.append(line)
221 self.logger.debug("pair-setup: %s", line)
222
223 async def _finish_cli_pair_setup(self, pin: str) -> str:
224 """
225 Complete HAP pairing by feeding the PIN to the cliairplay process.
226
227 The binary performs the full SRP/HomeKit pair-setup exchange and
228 prints ``CREDENTIALS: <192 hex chars>`` on stdout on success.
229
230 :param pin: 4-digit PIN (or device password).
231 """
232 # password-only devices skip start_pin_pairing, spawn the process now
233 await self._start_pair_setup_process()
234 proc = self._pair_proc
235 assert proc is not None # type guard
236 self.logger.info("Completing HAP pairing with PIN")
237 try:
238 await proc.write(f"{pin}\n".encode())
239 credentials = await asyncio.wait_for(
240 self._read_credentials(proc), timeout=PAIR_SETUP_TIMEOUT
241 )
242 returncode = await proc.wait_with_timeout(10)
243 except (TimeoutError, BrokenPipeError, ConnectionResetError) as err:
244 raise PlayerCommandFailed(f"Pairing failed: {self._pair_setup_error()}") from err
245 if not credentials or returncode != 0:
246 raise PlayerCommandFailed(
247 f"Pairing failed (exit code {returncode}): {self._pair_setup_error()}"
248 )
249 if len(credentials) != 192:
250 raise PlayerCommandFailed(
251 f"Pairing produced invalid credentials (length {len(credentials)})"
252 )
253 return credentials
254
255 async def _read_credentials(self, proc: AsyncProcess) -> str | None:
256 """Read the pair-setup process stdout until the CREDENTIALS line (or EOF)."""
257 buffer = b""
258 while chunk := await proc.read(1024):
259 buffer += chunk
260 while b"\n" in buffer:
261 raw_line, buffer = buffer.split(b"\n", 1)
262 line = raw_line.decode("utf-8", errors="ignore").strip()
263 if line.startswith("CREDENTIALS:"):
264 return line.split(":", 1)[1].strip()
265 return None
266
267 def _pair_setup_error(self) -> str:
268 """Return a short error description from the pair-setup stderr output."""
269 # the binary reports failures as plain lines on stderr; the last
270 # (non-prompt) line is the most specific one
271 for line in reversed(self._pair_proc_stderr):
272 if line and "Enter the PIN" not in line:
273 return line
274 return "no error details reported"
275
276 # ========================================================================
277 # RAOP (AirPlay 1 legacy) pairing implementation
278 # ========================================================================
279
280 def _compute_raop_premaster_secret(
281 self,
282 user_id: str,
283 password: str,
284 salt: bytes,
285 client_private: bytes,
286 client_public: bytes,
287 server_public: bytes,
288 ) -> bytes:
289 """
290 Compute RAOP SRP premaster secret S.
291
292 S = (B - k*v)^(a + u*x) mod N
293
294 :param user_id: Username (hex-encoded client_id).
295 :param password: PIN code.
296 :param salt: Salt from server.
297 :param client_private: Client private key (a) as bytes.
298 :param client_public: Client public key (A) as bytes.
299 :param server_public: Server public key (B) as bytes.
300 :return: Premaster secret S as bytes (padded to N length).
301 """
302 # Convert values to integers
303 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
304 n_len = len(n_bytes)
305 n = int.from_bytes(n_bytes, "big")
306 g = int.from_bytes(bytes.fromhex(RAOP_SRP_GENERATOR), "big")
307
308 a = int.from_bytes(client_private, "big")
309 b_pub = int.from_bytes(server_public, "big")
310
311 # x = H(s | H(I : P))
312 inner_hash = hashlib.sha1(f"{user_id}:{password}".encode()).digest()
313 x = int.from_bytes(hashlib.sha1(salt + inner_hash).digest(), "big")
314
315 # k = H(N | PAD(g))
316 g_padded = bytes.fromhex(RAOP_SRP_GENERATOR).rjust(n_len, b"\x00")
317 k = int.from_bytes(hashlib.sha1(n_bytes + g_padded).digest(), "big")
318
319 # u = H(PAD(A) | PAD(B))
320 a_padded = client_public.rjust(n_len, b"\x00")
321 b_padded = server_public.rjust(n_len, b"\x00")
322 u = int.from_bytes(hashlib.sha1(a_padded + b_padded).digest(), "big")
323
324 # v = g^x mod N
325 v = pow(g, x, n)
326
327 # S = (B - k*v)^(a + u*x) mod N
328 s_int = pow(b_pub - k * v, a + u * x, n)
329
330 # Convert to bytes and pad to N length
331 s_bytes = s_int.to_bytes((s_int.bit_length() + 7) // 8, "big")
332 return s_bytes.rjust(n_len, b"\x00")
333
334 def _compute_raop_session_key(self, premaster_secret: bytes) -> bytes:
335 r"""
336 Compute RAOP session key K from premaster secret S.
337
338 K = SHA1(S | \x00\x00\x00\x00) | SHA1(S | \x00\x00\x00\x01)
339
340 This produces a 40-byte key (two SHA1 hashes concatenated).
341
342 :param premaster_secret: The SRP premaster secret S.
343 :return: 40-byte session key K.
344 """
345 k1 = hashlib.sha1(premaster_secret + b"\x00\x00\x00\x00").digest()
346 k2 = hashlib.sha1(premaster_secret + b"\x00\x00\x00\x01").digest()
347 return k1 + k2
348
349 def _compute_raop_m1(
350 self, user_id: str, salt: bytes, client_pk: bytes, server_pk: bytes, session_key: bytes
351 ) -> bytes:
352 """
353 Compute RAOP SRP M1 proof with padding for A and B (but not g).
354
355 M1 = H(H(N) XOR H(g) | H(I) | s | PAD(A) | PAD(B) | K)
356
357 Note: g is NOT padded, but A and B ARE padded to N length.
358 K is 40 bytes (from _compute_raop_session_key).
359
360 :param user_id: Username (hex-encoded client_id).
361 :param salt: Salt bytes from server.
362 :param client_pk: Client public key (A).
363 :param server_pk: Server public key (B).
364 :param session_key: Session key (K) - 40 bytes.
365 :return: M1 proof bytes (20 bytes for SHA-1).
366 """
367 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
368 n_len = len(n_bytes)
369 g_bytes = bytes.fromhex(RAOP_SRP_GENERATOR)
370
371 # H(N) XOR H(g) - g is NOT padded
372 h_n = hashlib.sha1(n_bytes).digest()
373 h_g = hashlib.sha1(g_bytes).digest()
374 h_n_xor_h_g = bytes(a ^ b for a, b in zip(h_n, h_g, strict=True))
375
376 # H(I) - hash of username
377 h_i = hashlib.sha1(user_id.encode("ascii")).digest()
378
379 # PAD A and B to N length
380 a_padded = client_pk.rjust(n_len, b"\x00")
381 b_padded = server_pk.rjust(n_len, b"\x00")
382
383 # M1 = H(H(N) XOR H(g) | H(I) | s | PAD(A) | PAD(B) | K)
384 m1_data = h_n_xor_h_g + h_i + salt + a_padded + b_padded + session_key
385 return hashlib.sha1(m1_data).digest()
386
387 def _compute_raop_client_public(self, auth_secret: bytes) -> bytes:
388 """
389 Compute RAOP SRP client public key A = g^a mod N.
390
391 :param auth_secret: 32-byte random secret (used as SRP private key a).
392 :return: Client public key A as bytes.
393 """
394 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
395 n = int.from_bytes(n_bytes, "big")
396 g = int.from_bytes(bytes.fromhex(RAOP_SRP_GENERATOR), "big")
397 a = int.from_bytes(auth_secret, "big")
398 a_pub = pow(g, a, n)
399 return a_pub.to_bytes((a_pub.bit_length() + 7) // 8, "big")
400
401 async def _finish_raop_pairing(self, pin: str) -> str:
402 """
403 Complete RAOP pairing for AirPlay 1.
404
405 :param pin: 4-digit PIN.
406 :return: Credentials (client_id:auth_secret format).
407 """
408 if not self._session:
409 raise PlayerCommandFailed("Pairing not started")
410
411 self.logger.info("Completing RAOP pairing with PIN")
412
413 # Generate 32-byte auth secret
414 auth_secret = os.urandom(32)
415
416 # Derive Ed25519 public key from auth secret
417 # For RAOP, we use the auth_secret as the Ed25519 seed
418 auth_private_key = Ed25519PrivateKey.from_private_bytes(auth_secret)
419 auth_public_key = auth_private_key.public_key().public_bytes(
420 encoding=serialization.Encoding.Raw,
421 format=serialization.PublicFormat.Raw,
422 )
423
424 # Step 1: Send device ID and method
425 user_id = self._client_id.hex().upper()
426 step1_plist = {
427 "method": "pin",
428 "user": user_id,
429 }
430
431 async with self._session.post(
432 f"{self._base_url}/pair-setup-pin",
433 data=plistlib.dumps(step1_plist, fmt=plistlib.FMT_BINARY),
434 headers={"Content-Type": "application/x-apple-binary-plist"},
435 timeout=aiohttp.ClientTimeout(total=30),
436 ) as resp:
437 if resp.status != 200:
438 raise PlayerCommandFailed(f"RAOP step 1 failed: HTTP {resp.status}")
439 step1_response = plistlib.loads(await resp.read())
440
441 # Get salt and server public key
442 salt, server_pk = step1_response.get("salt"), step1_response.get("pk")
443 if not salt or not server_pk:
444 raise PlayerCommandFailed("Invalid RAOP step 1 response")
445
446 # Step 2: SRP authentication
447 # Apple uses a custom K formula: K = SHA1(S|0000) | SHA1(S|0001) (40 bytes)
448 client_pk = self._compute_raop_client_public(auth_secret)
449 premaster_secret = self._compute_raop_premaster_secret(
450 user_id, pin, salt, auth_secret, client_pk, server_pk
451 )
452 session_key = self._compute_raop_session_key(premaster_secret)
453 client_proof = self._compute_raop_m1(user_id, salt, client_pk, server_pk, session_key)
454
455 step2_plist = {
456 "pk": client_pk,
457 "proof": client_proof,
458 }
459
460 async with self._session.post(
461 f"{self._base_url}/pair-setup-pin",
462 data=plistlib.dumps(step2_plist, fmt=plistlib.FMT_BINARY),
463 headers={"Content-Type": "application/x-apple-binary-plist"},
464 timeout=aiohttp.ClientTimeout(total=30),
465 ) as resp:
466 if resp.status != 200:
467 raise PlayerCommandFailed(f"RAOP step 2 failed: HTTP {resp.status}")
468 step2_response = plistlib.loads(await resp.read())
469
470 # Verify server proof M2 exists (verification optional)
471 server_proof = step2_response.get("proof")
472 if not server_proof:
473 raise PlayerCommandFailed("RAOP server did not return proof")
474
475 # Step 3: Encrypt and send auth public key using AES-GCM
476 # Derive AES key and IV from session key K (40 bytes)
477 aes_key = hashlib.sha512(b"Pair-Setup-AES-Key" + session_key).digest()[:16]
478 aes_iv = bytearray(hashlib.sha512(b"Pair-Setup-AES-IV" + session_key).digest()[:16])
479 aes_iv[-1] = (aes_iv[-1] + 1) % 256 # Increment last byte
480
481 # Encrypt auth public key with AES-GCM
482 cipher = Cipher(algorithms.AES(aes_key), modes.GCM(bytes(aes_iv)))
483 encryptor = cipher.encryptor()
484 encrypted_pk = encryptor.update(auth_public_key) + encryptor.finalize()
485 tag = encryptor.tag
486
487 step3_plist = {
488 "epk": encrypted_pk,
489 "authTag": tag,
490 }
491
492 async with self._session.post(
493 f"{self._base_url}/pair-setup-pin",
494 data=plistlib.dumps(step3_plist, fmt=plistlib.FMT_BINARY),
495 headers={"Content-Type": "application/x-apple-binary-plist"},
496 timeout=aiohttp.ClientTimeout(total=30),
497 ) as resp:
498 if resp.status != 200:
499 raise PlayerCommandFailed(f"RAOP step 3 failed: HTTP {resp.status}")
500
501 # Return credentials in raop credentials format: client_id:auth_secret
502 return f"{self._client_id.hex()}:{auth_secret.hex()}"
503