/
/
/
1"""Native pairing implementations for AirPlay devices.
2
3This module provides pairing support for:
4- AirPlay 2 (HAP - HomeKit Accessory Protocol) - for Apple TV 4+, HomePod, Mac
5- RAOP (AirPlay 1 legacy pairing) - for older devices
6
7Both implementations produce credentials compatible with cliap2/cliraop.
8"""
9
10from __future__ import annotations
11
12import binascii
13import hashlib
14import logging
15import os
16import plistlib
17import uuid
18
19import aiohttp
20from cryptography.hazmat.primitives import hashes, serialization
21from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
22from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
23from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
24from cryptography.hazmat.primitives.kdf.hkdf import HKDF
25from music_assistant_models.errors import PlayerCommandFailed
26from srptools import SRPClientSession, SRPContext
27
28from .constants import StreamingProtocol
29
30# ============================================================================
31# Common utilities
32# ============================================================================
33
34
35def hkdf_derive(
36 input_key: bytes,
37 salt: bytes,
38 info: bytes,
39 length: int = 32,
40) -> bytes:
41 """Derive key using HKDF-SHA512.
42
43 :param input_key: Input keying material.
44 :param salt: Salt value.
45 :param info: Context info.
46 :param length: Output key length.
47 :return: Derived key bytes.
48 """
49 hkdf = HKDF(
50 algorithm=hashes.SHA512(),
51 length=length,
52 salt=salt,
53 info=info,
54 )
55 return hkdf.derive(input_key)
56
57
58# ============================================================================
59# TLV encoding/decoding for HAP
60# ============================================================================
61
62# TLV types for HAP pairing
63TLV_METHOD = 0x00
64TLV_IDENTIFIER = 0x01
65TLV_SALT = 0x02
66TLV_PUBLIC_KEY = 0x03
67TLV_PROOF = 0x04
68TLV_ENCRYPTED_DATA = 0x05
69TLV_STATE = 0x06
70TLV_ERROR = 0x07
71TLV_SIGNATURE = 0x0A
72
73
74def tlv_encode(items: list[tuple[int, bytes]]) -> bytes:
75 """Encode items into TLV format.
76
77 :param items: List of (type, value) tuples.
78 :return: TLV-encoded bytes.
79 """
80 result = bytearray()
81 for tlv_type, value in items:
82 offset = 0
83 while offset < len(value):
84 chunk = value[offset : offset + 255]
85 result.append(tlv_type)
86 result.append(len(chunk))
87 result.extend(chunk)
88 offset += 255
89 if len(value) == 0:
90 result.append(tlv_type)
91 result.append(0)
92 return bytes(result)
93
94
95def tlv_decode(data: bytes) -> dict[int, bytes]:
96 """Decode TLV format into dictionary.
97
98 :param data: TLV-encoded bytes.
99 :return: Dictionary mapping type to concatenated value.
100 """
101 result: dict[int, bytearray] = {}
102 offset = 0
103 while offset < len(data):
104 tlv_type = data[offset]
105 length = data[offset + 1]
106 value = data[offset + 2 : offset + 2 + length]
107 if tlv_type in result:
108 result[tlv_type].extend(value)
109 else:
110 result[tlv_type] = bytearray(value)
111 offset += 2 + length
112 return {k: bytes(v) for k, v in result.items()}
113
114
115# ============================================================================
116# HAP Pairing constants (for AirPlay 2)
117# ============================================================================
118
119# SRP 3072-bit prime for HAP (hex string format for srptools)
120HAP_SRP_PRIME_3072 = (
121 "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74"
122 "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437"
123 "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
124 "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05"
125 "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB"
126 "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
127 "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718"
128 "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33"
129 "A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
130 "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864"
131 "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2"
132 "08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
133)
134HAP_SRP_GENERATOR = "5"
135
136
137# ============================================================================
138# RAOP Pairing constants (for AirPlay 1 legacy)
139# ============================================================================
140
141# SRP 2048-bit prime for RAOP (hex string format for srptools)
142RAOP_SRP_PRIME_2048 = (
143 "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC319294"
144 "3DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310D"
145 "CD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FB"
146 "D5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF74"
147 "7359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A"
148 "436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D"
149 "5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E73"
150 "03CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6"
151 "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F"
152 "9E4AFF73"
153)
154RAOP_SRP_GENERATOR = "02" # RFC5054-2048bit uses generator 2
155
156
157# ============================================================================
158# Base Pairing class
159# ============================================================================
160
161
162class AirPlayPairing:
163 """Base class for AirPlay pairing.
164
165 Handles both HAP (AirPlay 2) and RAOP (AirPlay 1) pairing protocols.
166 """
167
168 def __init__(
169 self,
170 address: str,
171 name: str,
172 protocol: StreamingProtocol,
173 logger: logging.Logger,
174 port: int | None = None,
175 device_id: str | None = None,
176 ) -> None:
177 """Initialize AirPlay pairing.
178
179 :param address: IP address of the device.
180 :param name: Display name of the device.
181 :param protocol: Streaming protocol (RAOP or AIRPLAY2).
182 :param logger: Logger instance.
183 :param port: Port number (default: 7000 for AirPlay 2, 5000 for RAOP).
184 :param device_id: Device identifier (DACP ID) - must match what cliap2 uses.
185 """
186 self.address = address
187 self.name = name
188 self.protocol = protocol
189 self.logger = logger
190 self.port = port or (7000 if protocol == StreamingProtocol.AIRPLAY2 else 5000)
191
192 # HTTP session
193 self._session: aiohttp.ClientSession | None = None
194 self._base_url: str = f"http://{address}:{self.port}"
195
196 # Common state
197 self._is_pairing: bool = False
198 self._srp_context: SRPContext | None = None
199 self._srp_session: SRPClientSession | None = None
200 self._session_key: bytes | None = None
201
202 # Client identifier (device_id) handling depends on protocol:
203 # - HAP (AirPlay 2): Uses DACP ID as string identifier (must match cliap2 pair-verify)
204 # - RAOP: Uses 8 random bytes (not the DACP ID) - credentials are self-contained
205 if protocol == StreamingProtocol.AIRPLAY2:
206 # For HAP, use DACP ID as the identifier (must match pair-verify)
207 if device_id:
208 self._client_id: bytes = device_id.encode()
209 else:
210 self._client_id = str(uuid.uuid4()).encode()
211 else:
212 # For RAOP, generate 8 random bytes for client_id
213 # The credentials format is client_id_hex:auth_secret_hex
214 self._client_id = os.urandom(8)
215
216 # Ed25519 keypair
217 self._client_private_key: Ed25519PrivateKey | None = None
218 self._client_public_key: bytes | None = None
219
220 # Server's public key
221 self._server_public_key: bytes | None = None
222
223 @property
224 def is_pairing(self) -> bool:
225 """Return True if a pairing session is in progress."""
226 return self._is_pairing
227
228 @property
229 def device_provides_pin(self) -> bool:
230 """Return True if the device displays the PIN."""
231 return True # Both HAP and RAOP display PIN on device
232
233 @property
234 def protocol_name(self) -> str:
235 """Return human-readable protocol name."""
236 if self.protocol == StreamingProtocol.RAOP:
237 return "RAOP (AirPlay 1)"
238 return "AirPlay"
239
240 async def start_pairing(self) -> bool:
241 """Start the pairing process.
242
243 :return: True if device provides PIN (always True for AirPlay).
244 :raises PlayerCommandFailed: If device connection fails.
245 """
246 self.logger.info(
247 "Starting %s pairing with %s at %s:%d",
248 self.protocol_name,
249 self.name,
250 self.address,
251 self.port,
252 )
253
254 # Generate Ed25519 keypair
255 self._client_private_key = Ed25519PrivateKey.generate()
256 self._client_public_key = self._client_private_key.public_key().public_bytes(
257 encoding=serialization.Encoding.Raw,
258 format=serialization.PublicFormat.Raw,
259 )
260
261 # Create HTTP session
262 self._session = aiohttp.ClientSession()
263
264 try:
265 # Request PIN to be shown on device
266 async with self._session.post(
267 f"{self._base_url}/pair-pin-start",
268 timeout=aiohttp.ClientTimeout(total=10),
269 ) as resp:
270 if resp.status != 200:
271 raise PlayerCommandFailed(f"Failed to start pairing: HTTP {resp.status}")
272
273 self._is_pairing = True
274 self.logger.info("Device %s is displaying PIN", self.name)
275
276 # SRP context will be created in finish_pairing when we have the PIN
277 return True
278
279 except aiohttp.ClientError as err:
280 await self.close()
281 raise PlayerCommandFailed(f"Connection failed: {err}") from err
282
283 async def finish_pairing(self, pin: str) -> str:
284 """Complete pairing with the provided PIN.
285
286 :param pin: 4-digit PIN from device screen.
287 :return: Credentials string for cliap2/cliraop.
288 :raises PlayerCommandFailed: If pairing fails.
289 """
290 if not self._session:
291 raise PlayerCommandFailed("Pairing not started")
292
293 try:
294 if self.protocol == StreamingProtocol.AIRPLAY2:
295 return await self._finish_hap_pairing(pin)
296 return await self._finish_raop_pairing(pin)
297 except PlayerCommandFailed:
298 raise
299 except Exception as err:
300 self.logger.exception("Pairing failed")
301 raise PlayerCommandFailed(f"Pairing failed: {err}") from err
302 finally:
303 await self.close()
304
305 # ========================================================================
306 # HAP (AirPlay 2) pairing implementation
307 # ========================================================================
308
309 async def _finish_hap_pairing(self, pin: str) -> str:
310 """Complete HAP pairing for AirPlay 2.
311
312 :param pin: 4-digit PIN.
313 :return: Credentials (192 hex chars).
314 """
315 if not self._session:
316 raise PlayerCommandFailed("Pairing not started")
317
318 self.logger.info("Completing HAP pairing with PIN")
319
320 # HAP headers required for pair-setup
321 hap_headers = {
322 "Content-Type": "application/octet-stream",
323 "X-Apple-HKP": "3",
324 }
325
326 # M1: Send method request (state=1, method=0 for pair-setup)
327 m1_data = tlv_encode(
328 [
329 (TLV_METHOD, bytes([0x00])),
330 (TLV_STATE, bytes([0x01])),
331 ]
332 )
333
334 async with self._session.post(
335 f"{self._base_url}/pair-setup",
336 data=m1_data,
337 headers=hap_headers,
338 timeout=aiohttp.ClientTimeout(total=30),
339 ) as resp:
340 if resp.status != 200:
341 raise PlayerCommandFailed(f"M1 failed: HTTP {resp.status}")
342 m2_data = await resp.read()
343
344 # Parse M2
345 m2 = tlv_decode(m2_data)
346 if TLV_ERROR in m2:
347 raise PlayerCommandFailed(f"Device error in M2: {m2[TLV_ERROR].hex()}")
348
349 salt = m2.get(TLV_SALT)
350 server_pk_srp = m2.get(TLV_PUBLIC_KEY)
351 if not salt or not server_pk_srp:
352 raise PlayerCommandFailed("Invalid M2: missing salt or public key")
353
354 # M3: SRP authentication - create context with password
355 # PIN is passed directly as string (not "Pair-Setup:PIN")
356 # Note: pyatv doesn't specify bits_random, uses default
357 self._srp_context = SRPContext(
358 username="Pair-Setup",
359 password=pin,
360 prime=HAP_SRP_PRIME_3072,
361 generator=HAP_SRP_GENERATOR,
362 hash_func=hashlib.sha512,
363 )
364 # Pass Ed25519 private key bytes as the SRP "a" value (random private exponent)
365 # This is what pyatv does - use the client's Ed25519 private key as the SRP private value
366 if not self._client_private_key:
367 raise PlayerCommandFailed("Client private key not initialized")
368 auth_private = self._client_private_key.private_bytes(
369 encoding=serialization.Encoding.Raw,
370 format=serialization.PrivateFormat.Raw,
371 encryption_algorithm=serialization.NoEncryption(),
372 )
373 self._srp_session = SRPClientSession(
374 self._srp_context, binascii.hexlify(auth_private).decode()
375 )
376
377 # Process with server's public key and salt (as hex strings)
378 self._srp_session.process(server_pk_srp.hex(), salt.hex())
379
380 # Get client's public key and proof
381 client_pk_srp = bytes.fromhex(self._srp_session.public)
382 client_proof = bytes.fromhex(self._srp_session.key_proof.decode("ascii"))
383
384 m3_data = tlv_encode(
385 [
386 (TLV_STATE, bytes([0x03])),
387 (TLV_PUBLIC_KEY, client_pk_srp),
388 (TLV_PROOF, client_proof),
389 ]
390 )
391
392 async with self._session.post(
393 f"{self._base_url}/pair-setup",
394 data=m3_data,
395 headers=hap_headers,
396 timeout=aiohttp.ClientTimeout(total=30),
397 ) as resp:
398 if resp.status != 200:
399 raise PlayerCommandFailed(f"M3 failed: HTTP {resp.status}")
400 m4_data = await resp.read()
401
402 # Parse M4
403 m4 = tlv_decode(m4_data)
404 if TLV_ERROR in m4:
405 raise PlayerCommandFailed(f"Device error in M4: {m4[TLV_ERROR].hex()}")
406
407 server_proof = m4.get(TLV_PROOF)
408 if not server_proof:
409 raise PlayerCommandFailed("Invalid M4: missing proof")
410
411 # Verify server proof
412 if not self._srp_session.verify_proof(server_proof.hex().encode("ascii")):
413 raise PlayerCommandFailed("Server proof verification failed")
414
415 # Get session key
416 self._session_key = bytes.fromhex(self._srp_session.key.decode("ascii"))
417
418 # M5: Send encrypted client info
419 await self._send_hap_m5()
420
421 # Generate credentials
422 return self._generate_hap_credentials()
423
424 async def _send_hap_m5(self) -> None:
425 """Send M5 with encrypted client info and receive M6."""
426 if (
427 not self._session_key
428 or not self._client_private_key
429 or not self._client_public_key
430 or not self._session
431 ):
432 raise PlayerCommandFailed("Invalid state for M5")
433
434 # HAP headers required for pair-setup
435 hap_headers = {
436 "Content-Type": "application/octet-stream",
437 "X-Apple-HKP": "3",
438 }
439
440 # Derive keys
441 enc_key = hkdf_derive(
442 self._session_key,
443 b"Pair-Setup-Encrypt-Salt",
444 b"Pair-Setup-Encrypt-Info",
445 32,
446 )
447 sign_key = hkdf_derive(
448 self._session_key,
449 b"Pair-Setup-Controller-Sign-Salt",
450 b"Pair-Setup-Controller-Sign-Info",
451 32,
452 )
453
454 # Sign device info
455 device_info = sign_key + self._client_id + self._client_public_key
456 signature = self._client_private_key.sign(device_info)
457
458 # Create and encrypt inner TLV
459 inner_tlv = tlv_encode(
460 [
461 (TLV_IDENTIFIER, self._client_id),
462 (TLV_PUBLIC_KEY, self._client_public_key),
463 (TLV_SIGNATURE, signature),
464 ]
465 )
466
467 cipher = ChaCha20Poly1305(enc_key)
468 # Nonce format: 4 zero bytes + 8-byte message identifier = 12 bytes
469 nonce = b"\x00\x00\x00\x00PS-Msg05"
470 encrypted = cipher.encrypt(nonce, inner_tlv, None)
471
472 # Send M5
473 m5_data = tlv_encode(
474 [
475 (TLV_STATE, bytes([0x05])),
476 (TLV_ENCRYPTED_DATA, encrypted),
477 ]
478 )
479
480 async with self._session.post(
481 f"{self._base_url}/pair-setup",
482 data=m5_data,
483 headers=hap_headers,
484 timeout=aiohttp.ClientTimeout(total=30),
485 ) as resp:
486 if resp.status != 200:
487 raise PlayerCommandFailed(f"M5 failed: HTTP {resp.status}")
488 m6_data = await resp.read()
489
490 # Parse M6
491 m6 = tlv_decode(m6_data)
492 if TLV_ERROR in m6:
493 raise PlayerCommandFailed(f"Device error in M6: {m6[TLV_ERROR].hex()}")
494
495 encrypted_data = m6.get(TLV_ENCRYPTED_DATA)
496 if not encrypted_data:
497 raise PlayerCommandFailed("Invalid M6: missing encrypted data")
498
499 # Decrypt M6
500 # Nonce format: 4 zero bytes + 8-byte message identifier = 12 bytes
501 nonce = b"\x00\x00\x00\x00PS-Msg06"
502 decrypted = cipher.decrypt(nonce, encrypted_data, None)
503
504 # Extract server's public key
505 inner = tlv_decode(decrypted)
506 self._server_public_key = inner.get(TLV_PUBLIC_KEY)
507 if not self._server_public_key:
508 raise PlayerCommandFailed("Invalid M6: missing server public key")
509
510 def _generate_hap_credentials(self) -> str:
511 """Generate HAP credentials for cliap2.
512
513 Format: client_private_key(128 hex) + server_public_key(64 hex) = 192 hex chars
514
515 :return: Credentials string.
516 """
517 if (
518 not self._client_private_key
519 or not self._server_public_key
520 or not self._client_public_key
521 ):
522 raise PlayerCommandFailed("Missing keys for credential generation")
523
524 # Get raw private key (32 bytes seed)
525 private_key_bytes = self._client_private_key.private_bytes(
526 encoding=serialization.Encoding.Raw,
527 format=serialization.PrivateFormat.Raw,
528 encryption_algorithm=serialization.NoEncryption(),
529 )
530
531 # Expand to 64-byte Ed25519 secret key format (seed + public_key)
532 if len(private_key_bytes) == 32:
533 private_key_bytes = private_key_bytes + self._client_public_key
534
535 if len(private_key_bytes) != 64 or len(self._server_public_key) != 32:
536 raise PlayerCommandFailed("Invalid key lengths")
537
538 return binascii.hexlify(private_key_bytes).decode("ascii") + binascii.hexlify(
539 self._server_public_key
540 ).decode("ascii")
541
542 # ========================================================================
543 # RAOP (AirPlay 1 legacy) pairing implementation
544 # ========================================================================
545
546 def _compute_raop_premaster_secret(
547 self,
548 user_id: str,
549 password: str,
550 salt: bytes,
551 client_private: bytes,
552 client_public: bytes,
553 server_public: bytes,
554 ) -> bytes:
555 """Compute RAOP SRP premaster secret S.
556
557 S = (B - k*v)^(a + u*x) mod N
558
559 :param user_id: Username (hex-encoded client_id).
560 :param password: PIN code.
561 :param salt: Salt from server.
562 :param client_private: Client private key (a) as bytes.
563 :param client_public: Client public key (A) as bytes.
564 :param server_public: Server public key (B) as bytes.
565 :return: Premaster secret S as bytes (padded to N length).
566 """
567 # Convert values to integers
568 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
569 n_len = len(n_bytes)
570 n = int.from_bytes(n_bytes, "big")
571 g = int.from_bytes(bytes.fromhex(RAOP_SRP_GENERATOR), "big")
572
573 a = int.from_bytes(client_private, "big")
574 b_pub = int.from_bytes(server_public, "big")
575
576 # x = H(s | H(I : P))
577 inner_hash = hashlib.sha1(f"{user_id}:{password}".encode()).digest()
578 x = int.from_bytes(hashlib.sha1(salt + inner_hash).digest(), "big")
579
580 # k = H(N | PAD(g))
581 g_padded = bytes.fromhex(RAOP_SRP_GENERATOR).rjust(n_len, b"\x00")
582 k = int.from_bytes(hashlib.sha1(n_bytes + g_padded).digest(), "big")
583
584 # u = H(PAD(A) | PAD(B))
585 a_padded = client_public.rjust(n_len, b"\x00")
586 b_padded = server_public.rjust(n_len, b"\x00")
587 u = int.from_bytes(hashlib.sha1(a_padded + b_padded).digest(), "big")
588
589 # v = g^x mod N
590 v = pow(g, x, n)
591
592 # S = (B - k*v)^(a + u*x) mod N
593 s_int = pow(b_pub - k * v, a + u * x, n)
594
595 # Convert to bytes and pad to N length
596 s_bytes = s_int.to_bytes((s_int.bit_length() + 7) // 8, "big")
597 return s_bytes.rjust(n_len, b"\x00")
598
599 def _compute_raop_session_key(self, premaster_secret: bytes) -> bytes:
600 r"""Compute RAOP session key K from premaster secret S.
601
602 K = SHA1(S | \x00\x00\x00\x00) | SHA1(S | \x00\x00\x00\x01)
603
604 This produces a 40-byte key (two SHA1 hashes concatenated).
605
606 :param premaster_secret: The SRP premaster secret S.
607 :return: 40-byte session key K.
608 """
609 k1 = hashlib.sha1(premaster_secret + b"\x00\x00\x00\x00").digest()
610 k2 = hashlib.sha1(premaster_secret + b"\x00\x00\x00\x01").digest()
611 return k1 + k2
612
613 def _compute_raop_m1(
614 self, user_id: str, salt: bytes, client_pk: bytes, server_pk: bytes, session_key: bytes
615 ) -> bytes:
616 """Compute RAOP SRP M1 proof with padding for A and B (but not g).
617
618 M1 = H(H(N) XOR H(g) | H(I) | s | PAD(A) | PAD(B) | K)
619
620 Note: g is NOT padded, but A and B ARE padded to N length.
621 K is 40 bytes (from _compute_raop_session_key).
622
623 :param user_id: Username (hex-encoded client_id).
624 :param salt: Salt bytes from server.
625 :param client_pk: Client public key (A).
626 :param server_pk: Server public key (B).
627 :param session_key: Session key (K) - 40 bytes.
628 :return: M1 proof bytes (20 bytes for SHA-1).
629 """
630 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
631 n_len = len(n_bytes)
632 g_bytes = bytes.fromhex(RAOP_SRP_GENERATOR)
633
634 # H(N) XOR H(g) - g is NOT padded
635 h_n = hashlib.sha1(n_bytes).digest()
636 h_g = hashlib.sha1(g_bytes).digest()
637 h_n_xor_h_g = bytes(a ^ b for a, b in zip(h_n, h_g, strict=True))
638
639 # H(I) - hash of username
640 h_i = hashlib.sha1(user_id.encode("ascii")).digest()
641
642 # PAD A and B to N length
643 a_padded = client_pk.rjust(n_len, b"\x00")
644 b_padded = server_pk.rjust(n_len, b"\x00")
645
646 # M1 = H(H(N) XOR H(g) | H(I) | s | PAD(A) | PAD(B) | K)
647 m1_data = h_n_xor_h_g + h_i + salt + a_padded + b_padded + session_key
648 return hashlib.sha1(m1_data).digest()
649
650 def _compute_raop_client_public(self, auth_secret: bytes) -> bytes:
651 """Compute RAOP SRP client public key A = g^a mod N.
652
653 :param auth_secret: 32-byte random secret (used as SRP private key a).
654 :return: Client public key A as bytes.
655 """
656 n_bytes = bytes.fromhex(RAOP_SRP_PRIME_2048)
657 n = int.from_bytes(n_bytes, "big")
658 g = int.from_bytes(bytes.fromhex(RAOP_SRP_GENERATOR), "big")
659 a = int.from_bytes(auth_secret, "big")
660 a_pub = pow(g, a, n)
661 return a_pub.to_bytes((a_pub.bit_length() + 7) // 8, "big")
662
663 async def _finish_raop_pairing(self, pin: str) -> str:
664 """Complete RAOP pairing for AirPlay 1.
665
666 :param pin: 4-digit PIN.
667 :return: Credentials (client_id:auth_secret format).
668 """
669 if not self._session:
670 raise PlayerCommandFailed("Pairing not started")
671
672 self.logger.info("Completing RAOP pairing with PIN")
673
674 # Generate 32-byte auth secret
675 auth_secret = os.urandom(32)
676
677 # Derive Ed25519 public key from auth secret
678 # For RAOP, we use the auth_secret as the Ed25519 seed
679 from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: PLC0415
680 Ed25519PrivateKey as Ed25519Key,
681 )
682
683 auth_private_key = Ed25519Key.from_private_bytes(auth_secret)
684 auth_public_key = auth_private_key.public_key().public_bytes(
685 encoding=serialization.Encoding.Raw,
686 format=serialization.PublicFormat.Raw,
687 )
688
689 # Step 1: Send device ID and method
690 user_id = self._client_id.hex().upper()
691 step1_plist = {
692 "method": "pin",
693 "user": user_id,
694 }
695
696 async with self._session.post(
697 f"{self._base_url}/pair-setup-pin",
698 data=plistlib.dumps(step1_plist, fmt=plistlib.FMT_BINARY),
699 headers={"Content-Type": "application/x-apple-binary-plist"},
700 timeout=aiohttp.ClientTimeout(total=30),
701 ) as resp:
702 if resp.status != 200:
703 raise PlayerCommandFailed(f"RAOP step 1 failed: HTTP {resp.status}")
704 step1_response = plistlib.loads(await resp.read())
705
706 # Get salt and server public key
707 salt, server_pk = step1_response.get("salt"), step1_response.get("pk")
708 if not salt or not server_pk:
709 raise PlayerCommandFailed("Invalid RAOP step 1 response")
710
711 # Step 2: SRP authentication
712 # Apple uses a custom K formula: K = SHA1(S|0000) | SHA1(S|0001) (40 bytes)
713 client_pk = self._compute_raop_client_public(auth_secret)
714 premaster_secret = self._compute_raop_premaster_secret(
715 user_id, pin, salt, auth_secret, client_pk, server_pk
716 )
717 session_key = self._compute_raop_session_key(premaster_secret)
718 client_proof = self._compute_raop_m1(user_id, salt, client_pk, server_pk, session_key)
719
720 step2_plist = {
721 "pk": client_pk,
722 "proof": client_proof,
723 }
724
725 async with self._session.post(
726 f"{self._base_url}/pair-setup-pin",
727 data=plistlib.dumps(step2_plist, fmt=plistlib.FMT_BINARY),
728 headers={"Content-Type": "application/x-apple-binary-plist"},
729 timeout=aiohttp.ClientTimeout(total=30),
730 ) as resp:
731 if resp.status != 200:
732 raise PlayerCommandFailed(f"RAOP step 2 failed: HTTP {resp.status}")
733 step2_response = plistlib.loads(await resp.read())
734
735 # Verify server proof M2 exists (verification optional)
736 server_proof = step2_response.get("proof")
737 if not server_proof:
738 raise PlayerCommandFailed("RAOP server did not return proof")
739 self._session_key = session_key
740
741 # Step 3: Encrypt and send auth public key using AES-GCM
742 # Derive AES key and IV from session key K (40 bytes)
743 aes_key = hashlib.sha512(b"Pair-Setup-AES-Key" + session_key).digest()[:16]
744 aes_iv = bytearray(hashlib.sha512(b"Pair-Setup-AES-IV" + session_key).digest()[:16])
745 aes_iv[-1] = (aes_iv[-1] + 1) % 256 # Increment last byte
746
747 # Encrypt auth public key with AES-GCM
748 cipher = Cipher(algorithms.AES(aes_key), modes.GCM(bytes(aes_iv)))
749 encryptor = cipher.encryptor()
750 encrypted_pk = encryptor.update(auth_public_key) + encryptor.finalize()
751 tag = encryptor.tag
752
753 step3_plist = {
754 "epk": encrypted_pk,
755 "authTag": tag,
756 }
757
758 async with self._session.post(
759 f"{self._base_url}/pair-setup-pin",
760 data=plistlib.dumps(step3_plist, fmt=plistlib.FMT_BINARY),
761 headers={"Content-Type": "application/x-apple-binary-plist"},
762 timeout=aiohttp.ClientTimeout(total=30),
763 ) as resp:
764 if resp.status != 200:
765 raise PlayerCommandFailed(f"RAOP step 3 failed: HTTP {resp.status}")
766
767 # Return credentials in cliraop format: client_id:auth_secret
768 return f"{self._client_id.hex()}:{auth_secret.hex()}"
769
770 # ========================================================================
771 # Cleanup
772 # ========================================================================
773
774 async def close(self) -> None:
775 """Clean up resources."""
776 self._is_pairing = False
777 if self._session:
778 await self._session.close()
779 self._session = None
780 self._srp_context = None
781 self._srp_session = None
782 self._session_key = None
783