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