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