async def send_and_receive( self, method: str, uri: str, protocol: str = "HTTP/1.1", user_agent: str = USER_AGENT, content_type: Optional[str] = None, headers: Optional[Mapping[str, object]] = None, body: Optional[Union[str, bytes]] = None, allow_error: bool = False, ) -> HttpResponse: """Send a HTTP message and return response.""" output = _format_message(method, uri, protocol, user_agent, content_type, headers, body) _LOGGER.debug("Sending %s message: %s", protocol, output) if not self.transport: raise RuntimeError("not connected to remote") self.transport.write(self.send_processor(output)) event = asyncio.Event() self._requests.appendleft(event) try: await asyncio.wait_for(event.wait(), timeout=4) response = cast(HttpResponse, self._responses.get()) except asyncio.TimeoutError as ex: raise TimeoutError( f"no response to {method} {uri} ({protocol})") from ex finally: # If request failed and event is still in request queue, remove it if self._requests and self._requests[-1] == event: self._requests.pop() _LOGGER.debug("Got %s response: %s:", response.protocol, response) if response.code == 403: raise exceptions.AuthenticationError("not authenticated") # Password required if response.code == 401: if allow_error: return response raise exceptions.AuthenticationError("not authenticated") # Positive response if 200 <= response.code < 300 or allow_error: return response raise exceptions.HttpError( f"{protocol} method {method} failed with code " f"{response.code}: {response.message}", response.code, )
def _get_pairing_data(message: Dict[str, object]): pairing_data = message.get(PAIRING_DATA_KEY) if not pairing_data: raise exceptions.AuthenticationError("no pairing data in message") if not isinstance(pairing_data, bytes): raise exceptions.ProtocolError( f"Pairing data has unexpected type: {type(pairing_data)}") tlv = read_tlv(pairing_data) if TlvValue.Error in tlv: raise exceptions.AuthenticationError(stringify(tlv)) return tlv
async def _do(self, action, retry=True, is_login=False, is_daap=True): resp, status = await action() if is_daap: resp = parser.parse(resp, lookup_tag) self._log_response(action.log_text, resp, is_daap) if 200 <= status < 300: return resp # Seems to be the case? if status == 500: raise exceptions.NotSupportedError( "command not supported at this stage") if not is_login: # If a request fails, try to login again before retrying _LOGGER.info("implicitly logged out, logging in again") await self.login() # Retry once if we got a bad response, otherwise bail out if retry: return await self._do(action, False, is_login=is_login, is_daap=is_daap) raise exceptions.AuthenticationError("failed to login: " + str(status))
def step4(self, encrypted_data): """Last pairing step.""" chacha = chacha20.Chacha20Cipher(self._session_key, self._session_key) decrypted_tlv_bytes = chacha.decrypt( encrypted_data, nounce='PS-Msg06'.encode()) if not decrypted_tlv_bytes: raise exceptions.AuthenticationError('data decrypt failed') decrypted_tlv = tlv8.read_tlv(decrypted_tlv_bytes) _LOGGER.debug('PS-Msg06: %s', decrypted_tlv) atv_identifier = decrypted_tlv[tlv8.TLV_IDENTIFIER] atv_signature = decrypted_tlv[tlv8.TLV_SIGNATURE] atv_pub_key = decrypted_tlv[tlv8.TLV_PUBLIC_KEY] log_binary(_LOGGER, 'Device', Identifier=atv_identifier, Signature=atv_signature, Public=atv_pub_key) # TODO: verify signature here return Credentials(atv_pub_key, self._signing_key.to_seed(), atv_identifier, self.pairing_id)
def _get_pairing_data(resp): pairing_message = CryptoPairingMessage.cryptoPairingMessage tlv = tlv8.read_tlv(resp.Extensions[pairing_message].pairingData) if tlv8.TLV_ERROR in tlv: error = int.from_bytes(tlv[tlv8.TLV_ERROR], byteorder="little") raise exceptions.AuthenticationError("got error: " + str(error)) return tlv
def step4(self, encrypted_data): """Last pairing step.""" chacha = chacha20.Chacha20Cipher(self._session_key, self._session_key) decrypted_tlv_bytes = chacha.decrypt(encrypted_data, nounce="PS-Msg06".encode()) if not decrypted_tlv_bytes: raise exceptions.AuthenticationError("data decrypt failed") decrypted_tlv = hap_tlv8.read_tlv(decrypted_tlv_bytes) _LOGGER.debug("PS-Msg06: %s", decrypted_tlv) atv_identifier = decrypted_tlv[TlvValue.Identifier] atv_signature = decrypted_tlv[TlvValue.Signature] atv_pub_key = decrypted_tlv[TlvValue.PublicKey] log_binary( _LOGGER, "Device", Identifier=atv_identifier, Signature=atv_signature, Public=atv_pub_key, ) # TODO: verify signature here return HapCredentials( atv_pub_key, self._auth_private, atv_identifier, self.pairing_id )
def verify1(self, credentials, session_pub_key, encrypted): """First verification step.""" self._shared = self._verify_private.exchange( X25519PublicKey.from_public_bytes(session_pub_key) ) session_key = hkdf_expand( "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", self._shared ) chacha = chacha20.Chacha20Cipher(session_key, session_key) decrypted_tlv = hap_tlv8.read_tlv( chacha.decrypt(encrypted, nounce="PV-Msg02".encode()) ) identifier = decrypted_tlv[TlvValue.Identifier] signature = decrypted_tlv[TlvValue.Signature] if identifier != credentials.atv_id: raise exceptions.AuthenticationError("incorrect device response") info = session_pub_key + bytes(identifier) + self._public_bytes ltpk = Ed25519PublicKey.from_public_bytes(bytes(credentials.ltpk)) try: ltpk.verify(bytes(signature), bytes(info)) except InvalidSignature as ex: raise exceptions.AuthenticationError("signature error") from ex device_info = self._public_bytes + credentials.client_id + session_pub_key device_signature = Ed25519PrivateKey.from_private_bytes(credentials.ltsk).sign( device_info ) tlv = hap_tlv8.write_tlv( { TlvValue.Identifier: credentials.client_id, TlvValue.Signature: device_signature, } ) return chacha.encrypt(tlv, nounce="PV-Msg03".encode())
async def _setup_encryption(self): if self.service.credentials: credentials = HapCredentials.parse(self.service.credentials) pair_verifier = CompanionPairingVerifier(self, self.srp, credentials) try: await pair_verifier.verify_credentials() output_key, input_key = pair_verifier.encryption_keys() self.connection.enable_encryption(output_key, input_key) except Exception as ex: raise exceptions.AuthenticationError(str(ex)) from ex
async def _setup_encryption(self): if self.service.credentials: credentials = parse_credentials(self.service.credentials) pair_verifier = CompanionPairVerifyProcedure( self, self.srp, credentials) try: await pair_verifier.verify_credentials() output_key, input_key = pair_verifier.encryption_keys( SRP_SALT, SRP_OUTPUT_INFO, SRP_INPUT_INFO) self.connection.enable_encryption(output_key, input_key) except Exception as ex: raise exceptions.AuthenticationError(str(ex)) from ex
def decrypt(self, data, nounce=None): """Decrypt data with counter or specified nounce.""" if nounce is None: nounce = self._in_counter.to_bytes(length=8, byteorder='little') self._in_counter += 1 decrypted = self._enc_in.open( b'\x00\x00\x00\x00' + nounce, data, bytes()) if not decrypted: raise exceptions.AuthenticationError('data decrypt failed') return bytes(decrypted)
def step2(self, atv_pub_key, atv_salt): """Second pairing step.""" pk_str = binascii.hexlify(atv_pub_key).decode() salt = binascii.hexlify(atv_salt).decode() self._session.process(pk_str, salt) if not self._session.verify_proof(self._session.key_proof_hash): raise exceptions.AuthenticationError("proofs do not match") pub_key = binascii.unhexlify(self._session.public) proof = binascii.unhexlify(self._session.key_proof) log_binary(_LOGGER, "Client", Public=pub_key, Proof=proof) return pub_key, proof
def decrypt(self, data, nounce=None): """Decrypt data with counter or specified nounce.""" if nounce is None: nounce = self._in_counter.to_bytes(length=8, byteorder="little") self._in_counter += 1 decrypted = self._enc_in.decrypt(b"\x00\x00\x00\x00" + nounce, data, None) if not decrypted: raise exceptions.AuthenticationError("data decrypt failed") return bytes(decrypted)
def verify1(self, credentials, session_pub_key, encrypted): """First verification step.""" # No additional hashing used self._shared = self._verify_private.get_shared_key( curve25519.Public(session_pub_key), hashfunc=lambda x: x) session_key = hkdf_expand('Pair-Verify-Encrypt-Salt', 'Pair-Verify-Encrypt-Info', self._shared) chacha = chacha20.Chacha20Cipher(session_key, session_key) decrypted_tlv = tlv8.read_tlv( chacha.decrypt(encrypted, nounce='PV-Msg02'.encode())) identifier = decrypted_tlv[tlv8.TLV_IDENTIFIER] signature = decrypted_tlv[tlv8.TLV_SIGNATURE] if identifier != credentials.atv_id: raise exceptions.AuthenticationError('incorrect device response') info = session_pub_key + \ bytes(identifier) + self._verify_public.serialize() ltpk = VerifyingKey(bytes(credentials.ltpk)) try: ltpk.verify(bytes(signature), bytes(info)) except (BadPrefixError, BadSignatureError) as ex: raise exceptions.AuthenticationError('signature error') from ex device_info = self._verify_public.serialize() + \ credentials.client_id + session_pub_key device_signature = SigningKey(credentials.ltsk).sign(device_info) tlv = tlv8.write_tlv({tlv8.TLV_IDENTIFIER: credentials.client_id, tlv8.TLV_SIGNATURE: device_signature}) return chacha.encrypt(tlv, nounce='PV-Msg03'.encode())
async def _enable_encryption(self): # Encryption can be enabled whenever credentials are available but only # after DEVICE_INFORMATION has been sent if self.service.credentials: # Verify credentials and generate keys credentials = HapCredentials.parse(self.service.credentials) pair_verifier = MrpPairingVerifier(self, self.srp, credentials) try: await pair_verifier.verify_credentials() output_key, input_key = pair_verifier.encryption_keys() self.connection.enable_encryption(output_key, input_key) except Exception as ex: raise exceptions.AuthenticationError(str(ex)) from ex
def _do(self, action, url, retry=True, is_login=False, is_daap=True): resp, status = yield from action(url) self._log_response(str(action.__name__) + ': %s', resp, is_daap) if status >= 200 and status < 300: return resp # Retry once if we got a bad response, otherwise bail out if retry: return (yield from self._do(action, url, False, is_login=is_login, is_daap=is_daap)) else: raise exceptions.AuthenticationError('failed to login: ' + str(status))
async def _enable_encryption(self) -> None: # Encryption can be enabled whenever credentials are available but only # after DEVICE_INFORMATION has been sent if self.service.credentials is None: return # Verify credentials and generate keys credentials = parse_credentials(self.service.credentials) pair_verifier = MrpPairVerifyProcedure(self, self.srp, credentials) try: await pair_verifier.verify_credentials() output_key, input_key = pair_verifier.encryption_keys( SRP_SALT, SRP_OUTPUT_INFO, SRP_INPUT_INFO) self.connection.enable_encryption(output_key, input_key) except Exception as ex: raise exceptions.AuthenticationError(str(ex)) from ex
def step2(self, atv_pub_key, atv_salt): """Second pairing step.""" pk_str = binascii.hexlify(atv_pub_key).decode() print("B is: ", pk_str) salt = binascii.hexlify(atv_salt).decode() print("Salt is: ", salt) self._client_session_key, _, _ = self._session.process(pk_str, salt) if not self._session.verify_proof(self._session.key_proof_hash): raise exceptions.AuthenticationError('proofs do not match (mitm?)') pub_key = binascii.unhexlify(self._session.public) print("A is: ", pub_key) proof = binascii.unhexlify(self._session.key_proof) print("Proof is: ", proof) log_binary(_LOGGER, 'Client', Public=pub_key, Proof=proof) return pub_key, proof
async def _do(self, action, retry=True, is_login=False, is_daap=True): resp, status = await action() if is_daap: resp = parser.parse(resp, lookup_tag) self._log_response(str(action.__name__) + ': %s', resp, is_daap) if 200 <= status < 300: return resp if not is_login: # If a request fails, try to login again before retrying _LOGGER.info('implicitly logged out, logging in again') await self.login() # Retry once if we got a bad response, otherwise bail out if retry: return (await self._do( action, False, is_login=is_login, is_daap=is_daap)) raise exceptions.AuthenticationError( 'failed to login: ' + str(status))
def _do(self, action, retry=True, is_login=False, is_daap=True): resp, status = yield from action() self._log_response(str(action.__name__) + ': %s', resp, is_daap) if status >= 200 and status < 300: return resp # When a 403 is received we are likely logged out, so a new # login must be performed to get a new session id if status == 403: _LOGGER.info('implicitly logged out, logging in again') yield from self.login() # Retry once if we got a bad response, otherwise bail out if retry: return (yield from self._do(action, False, is_login=is_login, is_daap=is_daap)) else: raise exceptions.AuthenticationError('failed to login: ' + str(status))
async def play_url(self, url: str, position: float = 0) -> None: """Play media from an URL on the device.""" body = { "Content-Location": url, "Start-Position": position, "X-Apple-Session-ID": str(uuid4()), } retry = 0 while retry < PLAY_RETRIES: _LOGGER.debug("Starting to play %s", url) # pylint: disable=no-member resp = await self.http.post( "/play", headers=HEADERS, body=plistlib.dumps(body, fmt=plistlib.FMT_BINARY), allow_error=True, ) # Sometimes AirPlay fails with "Internal Server Error", we # apply a "lets try again"-approach to that if resp.code == 500: retry += 1 _LOGGER.debug("Failed to stream %s, retry %d of %d", url, retry, PLAY_RETRIES) await asyncio.sleep(1.0) continue # TODO: Should be more fine-grained if 400 <= resp.code < 600: raise exceptions.AuthenticationError( f"status code: {resp.code}") await self._wait_for_media_to_end() return raise exceptions.PlaybackError("Max retries exceeded")
async def play_url(self, url, position=0): """Play media from an URL on the device.""" body = { 'Content-Location': url, 'Start-Position': position, 'X-Apple-Session-ID': str(uuid4()), } retry = 0 while retry < PLAY_RETRIES: # pylint: disable=no-member _, status = await self.http.post_data('play', headers=HEADERS, data=plistlib.dumps( body, fmt=plistlib.FMT_BINARY), timeout=TIMEOUT) # Sometimes AirPlay fails with "Internal Server Error", we # apply a "lets try again"-approach to that if status == 500: retry += 1 _LOGGER.debug('Failed to stream %s, retry %d of %d', url, retry, PLAY_RETRIES) await asyncio.sleep(1.0, loop=self.loop) continue # TODO: Should be more fine-grained if 400 <= status < 600: raise exceptions.AuthenticationError('Status code: ' + str(status)) await self._wait_for_media_to_end() return raise exceptions.PlaybackError('Max retries exceeded')
def _get_pairing_data(resp): tlv = read_tlv(resp.inner().pairingData) if TlvValue.Error in tlv: raise exceptions.AuthenticationError(stringify(tlv)) return tlv