music-assistant-server

28.4 KBPY
pairing.py
28.4 KB783 lines • python
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