diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af0400d9..f8ee3dda 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 63917559..226663f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ Sections ### Developers --> +## [4.8.0] - 2023-10-06 + +- Add AccessoryInformation:HardwareFinish and NFCAccess characteristics/services. + [#454](https://github.com/ikalchev/HAP-python/pull/454) +- Fix handling of multiple pairings. [#456](https://github.com/ikalchev/HAP-python/pull/456) +- Save raw client username bytes if they are missing on successful pair verify.[#458](https://github.com/ikalchev/HAP-python/pull/458) +- Add support for Write Responses. [#459](https://github.com/ikalchev/HAP-python/pull/459) +- Ensure tasks are not garbage-collected before they finish. [#460](https://github.com/ikalchev/HAP-python/pull/460) + ## [4.7.1] - 2023-07-31 - Improve encryption performance. [#448](https://github.com/ikalchev/HAP-python/pull/448) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 73e2c57d..276e5ffc 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -45,6 +45,7 @@ HAP_REPR_STATUS, HAP_REPR_TTL, HAP_REPR_VALUE, + HAP_REPR_WRITE_RESPONSE, STANDALONE_AID, ) from pyhap.encoder import AccessoryEncoder @@ -71,7 +72,7 @@ def _wrap_char_setter(char, value, client_addr): """Process an characteristic setter callback trapping and logging all exceptions.""" try: - char.client_update_value(value, client_addr) + result = char.client_update_value(value, client_addr) except Exception: # pylint: disable=broad-except logger.exception( "%s: Error while setting characteristic %s to %s", @@ -79,8 +80,8 @@ def _wrap_char_setter(char, value, client_addr): char.display_name, value, ) - return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE - return HAP_SERVER_STATUS.SUCCESS + return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, None + return HAP_SERVER_STATUS.SUCCESS, result def _wrap_acc_setter(acc, updates_by_service, client_addr): @@ -615,7 +616,7 @@ def async_update_advertisement(self): self.mdns_service_info = AccessoryMDNSServiceInfo( self.accessory, self.state, self.zeroconf_server ) - asyncio.ensure_future( + util.async_create_background_task( self.advertiser.async_update_service(self.mdns_service_info) ) @@ -627,7 +628,7 @@ def async_persist(self): """ loop = asyncio.get_event_loop() logger.debug("Scheduling write of accessory state to disk") - asyncio.ensure_future(loop.run_in_executor(None, self.persist)) + util.async_create_background_task(loop.run_in_executor(None, self.persist)) def persist(self): """Saves the state of the accessory. @@ -851,6 +852,7 @@ def set_characteristics(self, chars_query, client_addr): "iid": 2, "value": False, # Value to set "ev": True # (Un)subscribe for events from this characteristics. + "r": True # Request write response }] } @@ -859,7 +861,9 @@ def set_characteristics(self, chars_query, client_addr): # TODO: Add support for chars that do no support notifications. updates = {} setter_results = {} + setter_responses = {} had_error = False + had_write_response = False expired = False if HAP_REPR_PID in chars_query: @@ -872,6 +876,10 @@ def set_characteristics(self, chars_query, client_addr): aid, iid = cq[HAP_REPR_AID], cq[HAP_REPR_IID] setter_results.setdefault(aid, {}) + if HAP_REPR_WRITE_RESPONSE in cq: + setter_responses.setdefault(aid, {}) + had_write_response = True + if expired: setter_results[aid][iid] = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST had_error = True @@ -904,11 +912,21 @@ def set_characteristics(self, chars_query, client_addr): # Characteristic level setter callbacks char = acc.get_characteristic(aid, iid) - set_result = _wrap_char_setter(char, value, client_addr) + set_result, set_result_value = _wrap_char_setter(char, value, client_addr) if set_result != HAP_SERVER_STATUS.SUCCESS: had_error = True + setter_results[aid][iid] = set_result + if set_result_value is not None: + if setter_responses.get(aid, None) is None: + logger.warning( + "Returning write response '%s' when it wasn't requested for %s %s", + set_result_value, aid, iid + ) + had_write_response = True + setter_responses.setdefault(aid, {})[iid] = set_result_value + if not char.service or ( not acc.setter_callback and not char.service.setter_callback ): @@ -934,7 +952,7 @@ def set_characteristics(self, chars_query, client_addr): for char in chars: setter_results[aid][char_to_iid[char]] = set_result - if not had_error: + if not had_error and not had_write_response: return None return { @@ -943,6 +961,11 @@ def set_characteristics(self, chars_query, client_addr): HAP_REPR_AID: aid, HAP_REPR_IID: iid, HAP_REPR_STATUS: status, + **( + {HAP_REPR_VALUE: setter_responses[aid][iid]} + if setter_responses.get(aid, {}).get(iid, None) is not None + else {} + ) } for aid, iid_status in setter_results.items() for iid, status in iid_status.items() diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index 286be2fb..e75d32c0 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -321,14 +321,16 @@ def client_update_value(self, value, sender_client_addr=None): ) previous_value = self.value self.value = value + response = None if self.setter_callback: # pylint: disable=not-callable - self.setter_callback(value) + response = self.setter_callback(value) changed = self.value != previous_value if changed: self.notify(sender_client_addr) if self.type_id in ALWAYS_NULL: self.value = None + return response def notify(self, sender_client_addr=None): """Notify clients about a value change. Sends the value. diff --git a/pyhap/const.py b/pyhap/const.py index 4f5dc60c..336e4d2c 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,7 +1,7 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 4 -MINOR_VERSION = 7 -PATCH_VERSION = 1 +MINOR_VERSION = 8 +PATCH_VERSION = 0 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7) @@ -78,6 +78,7 @@ HAP_REPR_TYPE = "type" HAP_REPR_VALUE = "value" HAP_REPR_VALID_VALUES = "valid-values" +HAP_REPR_WRITE_RESPONSE = "r" HAP_PROTOCOL_VERSION = "01.01.00" HAP_PROTOCOL_SHORT_VERSION = "1.1" diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 83d055b4..399bfcce 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -5,7 +5,7 @@ import asyncio from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Dict, Optional, Any from urllib.parse import ParseResult, parse_qs, urlparse import uuid @@ -88,6 +88,7 @@ class HAP_TLV_TAGS: ERROR_CODE = b"\x07" PROOF = b"\x0A" PERMISSIONS = b"\x0B" + SEPARATOR = b"\xFF" class UnprivilegedRequestException(Exception): @@ -148,7 +149,7 @@ def __init__(self, accessory_handler, client_address): """ self.accessory_handler: AccessoryDriver = accessory_handler self.state: State = self.accessory_handler.state - self.enc_context = None + self.enc_context: Optional[Dict[str, Any]] = None self.client_address = client_address self.is_encrypted = False self.client_uuid: Optional[uuid.UUID] = None @@ -567,24 +568,24 @@ def _pair_verify_two(self, tlv_objects: Dict[bytes, bytes]) -> None: dec_tlv_objects = tlv.decode(bytes(decrypted_data)) client_username = dec_tlv_objects[HAP_TLV_TAGS.USERNAME] - material = ( - self.enc_context["client_public"] - + client_username - + self.enc_context["public_key"].public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) + public_key: x25519.X25519PublicKey = self.enc_context["public_key"] + raw_public_key = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, ) + material = self.enc_context["client_public"] + client_username + raw_public_key client_uuid = uuid.UUID(str(client_username, "utf-8")) perm_client_public = self.state.paired_clients.get(client_uuid) if perm_client_public is None: logger.error( - "%s: Client %s with uuid %s attempted pair verify without being paired first (paired clients=%s).", + "%s: Client %s with uuid %s attempted pair verify " + "without being paired first (public_key=%s, paired clients=%s).", + self.accessory_handler.accessory.display_name, self.client_address, client_uuid, - self.state.paired_clients, - self.accessory_handler.accessory.display_name, + raw_public_key.hex(), + {uuid: key.hex() for uuid, key in self.state.paired_clients.items()}, ) self._send_authentication_error_tlv_response(HAP_TLV_STATES.M4) return @@ -592,8 +593,8 @@ def _pair_verify_two(self, tlv_objects: Dict[bytes, bytes]) -> None: verifying_key = ed25519.Ed25519PublicKey.from_public_bytes(perm_client_public) try: verifying_key.verify(dec_tlv_objects[HAP_TLV_TAGS.PROOF], material) - except InvalidSignature: - logger.error("%s: Bad signature, abort.", self.client_address) + except (InvalidSignature, KeyError) as ex: + logger.error("%s: %s, abort.", self.client_address, ex) self._send_authentication_error_tlv_response(HAP_TLV_STATES.M4) return @@ -605,6 +606,13 @@ def _pair_verify_two(self, tlv_objects: Dict[bytes, bytes]) -> None: data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, HAP_TLV_STATES.M4) self._send_tlv_pairing_response(data) + + if client_uuid not in self.state.uuid_to_bytes: + # We are missing the raw bytes for this client, so we need to + # add them to the state and persist so list pairings works. + self.state.uuid_to_bytes[client_uuid] = client_username + self.accessory_handler.async_persist() + assert self.response is not None # nosec self.response.shared_key = self.enc_context["shared_key"] self.is_encrypted = True @@ -781,9 +789,16 @@ def _handle_list_pairings(self) -> None: client_public, HAP_TLV_TAGS.PERMISSIONS, HAP_PERMISSIONS.ADMIN if admin else HAP_PERMISSIONS.USER, + HAP_TLV_TAGS.SEPARATOR, + b"", ] ) + if response[-2] == HAP_TLV_TAGS.SEPARATOR: + # The last pairing should not have a separator + response.pop() + response.pop() + data = tlv.encode(*response) self._send_tlv_pairing_response(data) diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 597dfb5f..0fdd8a4e 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -15,6 +15,7 @@ from .hap_crypto import HAPCrypto from .hap_event import create_hap_event from .hap_handler import HAPResponse, HAPServerHandler +from .util import async_create_background_task logger = logging.getLogger(__name__) @@ -270,7 +271,7 @@ def _process_response(self, response) -> None: self.hap_crypto = HAPCrypto(response.shared_key) # Only update mDNS after sending the response if response.pairing_changed: - asyncio.ensure_future( + async_create_background_task( self.loop.run_in_executor(None, self.accessory_driver.finish_pair) ) diff --git a/pyhap/params.py b/pyhap/params.py index d06a9d78..8fdd02e0 100644 --- a/pyhap/params.py +++ b/pyhap/params.py @@ -25,7 +25,6 @@ def get_srp_context(ng_group_len, hashfunc, salt_len=16, secret_len=32): - group = _ng_const[ng_order.index(ng_group_len)] ctx = { diff --git a/pyhap/resources/characteristics.json b/pyhap/resources/characteristics.json index 18bb5b6f..b14dc654 100644 --- a/pyhap/resources/characteristics.json +++ b/pyhap/resources/characteristics.json @@ -1664,5 +1664,36 @@ "maxValue": 100, "minValue": 0, "unit": "percentage" + }, + "HardwareFinish": { + "Format": "tlv8", + "Permissions": [ + "pr" + ], + "UUID": "0000026C-0000-1000-8000-0026BB765291" + }, + "ConfigurationState": { + "Format": "uint16", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00000263-0000-1000-8000-0026BB765291" + }, + "NFCAccessControlPoint": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00000264-0000-1000-8000-0026BB765291" + }, + "NFCAccessSupportedConfiguration": { + "Format": "tlv8", + "Permissions": [ + "pr" + ], + "UUID": "00000265-0000-1000-8000-0026BB765291" } } diff --git a/pyhap/resources/services.json b/pyhap/resources/services.json index 9f45e845..7c386279 100644 --- a/pyhap/resources/services.json +++ b/pyhap/resources/services.json @@ -2,7 +2,8 @@ "AccessoryInformation": { "OptionalCharacteristics": [ "HardwareRevision", - "AccessoryFlags" + "AccessoryFlags", + "HardwareFinish" ], "RequiredCharacteristics": [ "Identify", @@ -576,5 +577,14 @@ "PositionState" ], "UUID": "0000008C-0000-1000-8000-0026BB765291" + }, + "NFCAccess": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "ConfigurationState", + "NFCAccessControlPoint", + "NFCAccessSupportedConfiguration" + ], + "UUID": "00000266-0000-1000-8000-0026BB765291" } } diff --git a/pyhap/state.py b/pyhap/state.py index 70eab877..3503b976 100644 --- a/pyhap/state.py +++ b/pyhap/state.py @@ -42,13 +42,13 @@ def __init__( self.addresses = address else: self.addresses = [util.get_local_address()] - self.mac = mac or util.generate_mac() + self.mac: str = mac or util.generate_mac() self.pincode = pincode or util.generate_pincode() self.port = port or DEFAULT_PORT self.setup_id = util.generate_setup_id() self.config_version = DEFAULT_CONFIG_VERSION - self.paired_clients = {} + self.paired_clients: Dict[UUID, bytes] = {} self.client_properties = {} self.private_key = ed25519.Ed25519PrivateKey.generate() diff --git a/pyhap/tlv.py b/pyhap/tlv.py index d43a3276..4d87f5f0 100644 --- a/pyhap/tlv.py +++ b/pyhap/tlv.py @@ -1,5 +1,6 @@ """Encodes and decodes Tag-Length-Value (tlv8) data.""" import struct +from typing import Any, Dict from pyhap import util @@ -42,7 +43,7 @@ def encode(*args, to_base64=False): return util.to_base64_str(result) if to_base64 else result -def decode(data, from_base64=False): +def decode(data: bytes, from_base64: bool = False) -> Dict[bytes, Any]: """Decode the given TLV-encoded ``data`` to a ``dict``. :param from_base64: Whether the given ``data`` should be base64 decoded first. diff --git a/pyhap/util.py b/pyhap/util.py index 8aa31636..312c61ae 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -4,6 +4,7 @@ import random import socket from uuid import UUID +from typing import Awaitable, Set import async_timeout import orjson @@ -12,6 +13,8 @@ ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" HEX_DIGITS = "0123456789ABCDEF" +_BACKGROUND_TASKS: Set[asyncio.Task] = set() + rand = random.SystemRandom() @@ -175,3 +178,11 @@ def to_sorted_hap_json(dump_obj): def from_hap_json(json_str): """Convert json to an object.""" return orjson.loads(json_str) # pylint: disable=no-member + + +def async_create_background_task(func: Awaitable) -> asyncio.Task: + """Create a background task and add it to the set of background tasks.""" + task = asyncio.ensure_future(func) + _BACKGROUND_TASKS.add(task) + task.add_done_callback(_BACKGROUND_TASKS.discard) + return task diff --git a/tests/conftest.py b/tests/conftest.py index 37bc86f9..c6c6bedd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,12 @@ def driver(async_zeroconf): yield AccessoryDriver(loop=loop) +@pytest.fixture(autouse=True) +def mock_local_address(): + with patch("pyhap.util.get_local_address", return_value="127.0.0.1"): + yield + + class MockDriver: def __init__(self): self.loader = Loader() diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index de656984..e6f50e0f 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -25,6 +25,7 @@ HAP_REPR_IID, HAP_REPR_STATUS, HAP_REPR_VALUE, + HAP_REPR_WRITE_RESPONSE, HAP_SERVER_STATUS, ) from pyhap.service import Service @@ -142,6 +143,121 @@ def test_advertised_address(): assert driver.state.addresses == ["1.2.3.4", "::1"] +def test_write_response_returned_when_not_requested(driver: AccessoryDriver): + bridge = Bridge(driver, "mybridge") + acc = Accessory(driver, "TestAcc", aid=2) + service = Service(uuid1(), "NFCAccess") + char_nfc_access_control_point = Characteristic("NFCAccessControlPoint", uuid1(), CHAR_PROPS) + service.add_characteristic(char_nfc_access_control_point) + + mock_callback = MagicMock() + service.setter_callback = mock_callback + + def setter_with_write_response(value=0): + return 1 + + char_nfc_access_control_point.setter_callback = setter_with_write_response + acc.add_service(service) + + char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[HAP_REPR_IID] + + bridge.add_accessory(acc) + driver.add_accessory(bridge) + + response = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_VALUE: 0, + HAP_REPR_WRITE_RESPONSE: False + } + ] + }, + "mock_addr", + ) + assert response == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_STATUS: 0, + HAP_REPR_VALUE: 1 + }, + ] + } + + response = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_VALUE: 0, + } + ] + }, + "mock_addr", + ) + assert response == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_STATUS: 0, + HAP_REPR_VALUE: 1 + }, + ] + } + + +def test_write_response_returned_when_requested(driver: AccessoryDriver): + bridge = Bridge(driver, "mybridge") + acc = Accessory(driver, "TestAcc", aid=2) + service = Service(uuid1(), "NFCAccess") + char_nfc_access_control_point = Characteristic("NFCAccessControlPoint", uuid1(), CHAR_PROPS) + service.add_characteristic(char_nfc_access_control_point) + + mock_callback = MagicMock() + service.setter_callback = mock_callback + + def setter_with_write_response(value=0): + return 1 + + char_nfc_access_control_point.setter_callback = setter_with_write_response + acc.add_service(service) + + char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[HAP_REPR_IID] + + bridge.add_accessory(acc) + driver.add_accessory(bridge) + + response = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_VALUE: 0, + HAP_REPR_WRITE_RESPONSE: True + } + ] + }, + "mock_addr", + ) + assert response == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_STATUS: 0, + HAP_REPR_VALUE: 1 + }, + ] + } + + def test_service_callbacks(driver: AccessoryDriver): bridge = Bridge(driver, "mybridge") acc = Accessory(driver, "TestAcc", aid=2) diff --git a/tests/test_camera.py b/tests/test_camera.py index 43238eb5..726a37ee 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -86,7 +86,7 @@ def test_setup_endpoints(mock_driver): def test_set_selected_stream_start_stop(mock_driver): - """Test starting a stream request""" + """Test starting a stream request.""" # mocks for asyncio.Process async def communicate(): return (None, "stderr") diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 554f9679..75cd0d4f 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -1,18 +1,20 @@ """Tests for the HAPServerHandler.""" - import json from unittest.mock import patch from urllib.parse import urlparse from uuid import UUID +from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519, x25519 import pytest from pyhap import hap_handler, tlv from pyhap.accessory import Accessory, Bridge +from pyhap.accessory_driver import AccessoryDriver from pyhap.characteristic import CharacteristicError from pyhap.const import HAP_PERMISSIONS -from pyhap.accessory_driver import AccessoryDriver CLIENT_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1") CLIENT_UUID_BYTES = str(CLIENT_UUID).upper().encode("utf-8") @@ -20,6 +22,7 @@ CLIENT2_UUID_BYTES = str(CLIENT2_UUID).upper().encode("utf-8") PUBLIC_KEY = b"\x99\x98d%\x8c\xf6h\x06\xfa\x85\x9f\x90\x82\xf2\xe8\x18\x9f\xf8\xc75\x1f>~\xc32\xc1OC\x13\xbfH\xad" +PUBLIC_KEY2 = b"\x99\x98d%\x8c\xf6h\x06\xfa\x85\x9f\x90\x82\xf2\xe8\x18\x9f\xf8\xc75\x1f>~\xc32\xc1OC\x13\xbfH\xac" def test_response(): @@ -54,7 +57,7 @@ def test_list_pairings_unencrypted(driver: AccessoryDriver): } -def test_list_pairings(driver): +def test_list_pairings(driver: AccessoryDriver): """Verify an encrypted list pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -81,7 +84,40 @@ def test_list_pairings(driver): } -def test_add_pairing_admin(driver): +def test_list_pairings_multiple(driver: AccessoryDriver): + """Verify an encrypted list pairings request.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = True + handler.client_uuid = CLIENT_UUID + driver.pair(CLIENT_UUID_BYTES, PUBLIC_KEY, HAP_PERMISSIONS.ADMIN) + assert CLIENT_UUID in driver.state.paired_clients + driver.pair(CLIENT2_UUID_BYTES, PUBLIC_KEY2, HAP_PERMISSIONS.USER) + + assert driver.state.paired is True + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M5 + ) + handler.handle_pairings() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M2, + hap_handler.HAP_TLV_TAGS.USERNAME: str(CLIENT_UUID).encode("utf8").upper() + + str(CLIENT2_UUID).encode("utf8").upper(), + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY: PUBLIC_KEY + PUBLIC_KEY2, + hap_handler.HAP_TLV_TAGS.PERMISSIONS: hap_handler.HAP_PERMISSIONS.ADMIN + + hap_handler.HAP_PERMISSIONS.USER, + hap_handler.HAP_TLV_TAGS.SEPARATOR: b"", + } + + +def test_add_pairing_admin(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -97,7 +133,7 @@ def test_add_pairing_admin(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M3, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT2_UUID).encode("utf-8"), + CLIENT2_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, hap_handler.HAP_TLV_TAGS.PERMISSIONS, @@ -112,7 +148,7 @@ def test_add_pairing_admin(driver): assert driver.state.is_admin(CLIENT2_UUID) -def test_add_pairing_user(driver): +def test_add_pairing_user(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -128,7 +164,7 @@ def test_add_pairing_user(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M3, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT2_UUID).encode("utf-8"), + CLIENT2_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, hap_handler.HAP_TLV_TAGS.PERMISSIONS, @@ -149,7 +185,7 @@ def test_add_pairing_user(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M3, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT2_UUID).encode("utf-8"), + CLIENT2_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, hap_handler.HAP_TLV_TAGS.PERMISSIONS, @@ -170,7 +206,7 @@ def test_add_pairing_user(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M3, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT2_UUID).encode("utf-8"), + CLIENT2_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, hap_handler.HAP_TLV_TAGS.PERMISSIONS, @@ -185,7 +221,7 @@ def test_add_pairing_user(driver): assert not driver.state.is_admin(CLIENT2_UUID) -def test_remove_pairing(driver): +def test_remove_pairing(driver: AccessoryDriver): """Verify an encrypted remove pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -206,7 +242,7 @@ def test_remove_pairing(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M4, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT2_UUID).encode("utf-8"), + CLIENT2_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, ) @@ -224,7 +260,7 @@ def test_remove_pairing(driver): hap_handler.HAP_TLV_TAGS.REQUEST_TYPE, hap_handler.HAP_TLV_STATES.M4, hap_handler.HAP_TLV_TAGS.USERNAME, - str(CLIENT_UUID).encode("utf-8"), + CLIENT_UUID_BYTES, hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, PUBLIC_KEY, ) @@ -236,7 +272,7 @@ def test_remove_pairing(driver): assert driver.state.paired is False -def test_non_admin_pairings_request(driver): +def test_non_admin_pairings_request(driver: AccessoryDriver): """Verify only admins can access pairings.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -260,7 +296,7 @@ def test_non_admin_pairings_request(driver): } -def test_invalid_pairings_request(driver): +def test_invalid_pairings_request(driver: AccessoryDriver): """Verify an encrypted invalid pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -281,7 +317,7 @@ def test_invalid_pairings_request(driver): handler.handle_pairings() -def test_pair_verify_one(driver): +def test_pair_verify_one(driver: AccessoryDriver): """Verify an unencrypted pair verify one.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -308,7 +344,7 @@ def test_pair_verify_one(driver): ) -def test_pair_verify_one_not_paired(driver): +def test_pair_verify_one_not_paired(driver: AccessoryDriver): """Verify an unencrypted pair verify one.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -333,7 +369,7 @@ def test_pair_verify_one_not_paired(driver): } -def test_pair_verify_two_invaild_state(driver): +def test_pair_verify_two_invalid_state(driver: AccessoryDriver): """Verify an unencrypted pair verify two.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -377,7 +413,221 @@ def test_pair_verify_two_invaild_state(driver): } -def test_invalid_pairing_request(driver): +def test_pair_verify_two_missing_signature(driver: AccessoryDriver): + """Verify a pair verify two with a missing signature.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + driver.pair(CLIENT_UUID_BYTES, PUBLIC_KEY, HAP_PERMISSIONS.ADMIN) + assert CLIENT_UUID in driver.state.paired_clients + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M1, + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, + PUBLIC_KEY, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert ( + tlv_objects[hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM] + == hap_handler.HAP_TLV_STATES.M2 + ) + + unencrypted_data = tlv.encode( + hap_handler.HAP_TLV_TAGS.USERNAME, + CLIENT_UUID_BYTES, + ) + cipher = ChaCha20Poly1305(handler.enc_context["pre_session_key"]) + encrypted_data = cipher.encrypt( + hap_handler.HAPServerHandler.PVERIFY_2_NONCE, bytes(unencrypted_data), b"" + ) + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M3, + hap_handler.HAP_TLV_TAGS.ENCRYPTED_DATA, + encrypted_data, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M4, + hap_handler.HAP_TLV_TAGS.ERROR_CODE: hap_handler.HAP_TLV_ERRORS.AUTHENTICATION, + } + + +def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver): + """Verify a pair verify two populated missing raw bytes.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + client_private_key = ed25519.Ed25519PrivateKey.generate() + client_public_key = client_private_key.public_key() + + client_public_key_bytes = client_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + driver.pair(CLIENT_UUID_BYTES, client_public_key_bytes, HAP_PERMISSIONS.ADMIN) + + # We used to not save the raw bytes of the username, so we need to + # remove the entry to simulate that. + del driver.state.uuid_to_bytes[CLIENT_UUID] + + assert CLIENT_UUID in driver.state.paired_clients + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M1, + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, + client_public_key_bytes, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert ( + tlv_objects[hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM] + == hap_handler.HAP_TLV_STATES.M2 + ) + raw_accessory_public_key = tlv_objects[hap_handler.HAP_TLV_TAGS.PUBLIC_KEY] + + server_public_key: x25519.X25519PublicKey = handler.enc_context["public_key"] + expected_raw_public_key = server_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + assert raw_accessory_public_key == expected_raw_public_key + + assert client_public_key_bytes == handler.enc_context["client_public"] + + material = client_public_key_bytes + CLIENT_UUID_BYTES + raw_accessory_public_key + client_proof = client_private_key.sign(material) + + unencrypted_data = tlv.encode( + hap_handler.HAP_TLV_TAGS.USERNAME, + CLIENT_UUID_BYTES, + hap_handler.HAP_TLV_TAGS.PROOF, + client_proof, + ) + cipher = ChaCha20Poly1305(handler.enc_context["pre_session_key"]) + encrypted_data = cipher.encrypt( + hap_handler.HAPServerHandler.PVERIFY_2_NONCE, bytes(unencrypted_data), b"" + ) + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M3, + hap_handler.HAP_TLV_TAGS.ENCRYPTED_DATA, + encrypted_data, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M4, + } + assert handler.is_encrypted is True + assert handler.client_uuid == CLIENT_UUID + # Verify we saved the raw bytes of the username + assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES + + +def test_pair_verify_two_success(driver: AccessoryDriver): + """Verify a pair verify two.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + client_private_key = ed25519.Ed25519PrivateKey.generate() + client_public_key = client_private_key.public_key() + + client_public_key_bytes = client_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + driver.pair(CLIENT_UUID_BYTES, client_public_key_bytes, HAP_PERMISSIONS.ADMIN) + + assert CLIENT_UUID in driver.state.paired_clients + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M1, + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, + client_public_key_bytes, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert ( + tlv_objects[hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM] + == hap_handler.HAP_TLV_STATES.M2 + ) + raw_accessory_public_key = tlv_objects[hap_handler.HAP_TLV_TAGS.PUBLIC_KEY] + + server_public_key: x25519.X25519PublicKey = handler.enc_context["public_key"] + expected_raw_public_key = server_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + assert raw_accessory_public_key == expected_raw_public_key + + assert client_public_key_bytes == handler.enc_context["client_public"] + + material = client_public_key_bytes + CLIENT_UUID_BYTES + raw_accessory_public_key + client_proof = client_private_key.sign(material) + + unencrypted_data = tlv.encode( + hap_handler.HAP_TLV_TAGS.USERNAME, + CLIENT_UUID_BYTES, + hap_handler.HAP_TLV_TAGS.PROOF, + client_proof, + ) + cipher = ChaCha20Poly1305(handler.enc_context["pre_session_key"]) + encrypted_data = cipher.encrypt( + hap_handler.HAPServerHandler.PVERIFY_2_NONCE, bytes(unencrypted_data), b"" + ) + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M3, + hap_handler.HAP_TLV_TAGS.ENCRYPTED_DATA, + encrypted_data, + ) + handler.handle_pair_verify() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M4, + } + assert handler.is_encrypted is True + assert handler.client_uuid == CLIENT_UUID + assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES + + +def test_invalid_pairing_request(driver: AccessoryDriver): """Verify an unencrypted pair verify with an invalid sequence fails.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -398,7 +648,7 @@ def test_invalid_pairing_request(driver): handler.handle_pair_verify() -def test_handle_set_handle_set_characteristics_unencrypted(driver): +def test_handle_set_handle_set_characteristics_unencrypted(driver: AccessoryDriver): """Verify an unencrypted set_characteristics.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -417,7 +667,7 @@ def test_handle_set_handle_set_characteristics_unencrypted(driver): assert response.status_code == 401 -def test_handle_set_handle_set_characteristics_encrypted(driver): +def test_handle_set_handle_set_characteristics_encrypted(driver: AccessoryDriver): """Verify an encrypted set_characteristics.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -437,7 +687,9 @@ def test_handle_set_handle_set_characteristics_encrypted(driver): assert response.body == b"" -def test_handle_set_handle_set_characteristics_encrypted_pid_missing_prepare(driver): +def test_handle_set_handle_set_characteristics_encrypted_pid_missing_prepare( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics with a missing prepare.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -459,7 +711,9 @@ def test_handle_set_handle_set_characteristics_encrypted_pid_missing_prepare(dri assert b"-70410" in response.body -def test_handle_set_handle_set_characteristics_encrypted_with_prepare(driver): +def test_handle_set_handle_set_characteristics_encrypted_with_prepare( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics with a prepare.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -489,7 +743,9 @@ def test_handle_set_handle_set_characteristics_encrypted_with_prepare(driver): assert response.body == b"" -def test_handle_set_handle_set_characteristics_encrypted_with_multiple_prepare(driver): +def test_handle_set_handle_set_characteristics_encrypted_with_multiple_prepare( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics with multiple prepares.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -525,7 +781,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_multiple_prepare(d assert response.body == b"" -def test_handle_set_handle_encrypted_with_invalid_prepare(driver): +def test_handle_set_handle_encrypted_with_invalid_prepare(driver: AccessoryDriver): """Verify an encrypted set_characteristics with a prepare missing the ttl.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -545,7 +801,9 @@ def test_handle_set_handle_encrypted_with_invalid_prepare(driver): assert response.body == b'{"status":-70410}' -def test_handle_set_handle_set_characteristics_encrypted_with_expired_ttl(driver): +def test_handle_set_handle_set_characteristics_encrypted_with_expired_ttl( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics with a prepare expired.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -575,7 +833,9 @@ def test_handle_set_handle_set_characteristics_encrypted_with_expired_ttl(driver assert b"-70410" in response.body -def test_handle_set_handle_set_characteristics_encrypted_with_wrong_pid(driver): +def test_handle_set_handle_set_characteristics_encrypted_with_wrong_pid( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics with wrong pid.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -605,7 +865,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_wrong_pid(driver): assert b"-70410" in response.body -def test_handle_set_handle_prepare_not_encrypted(driver): +def test_handle_set_handle_prepare_not_encrypted(driver: AccessoryDriver): """Verify an non-encrypted set_characteristics with a prepare.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -624,7 +884,9 @@ def test_handle_set_handle_prepare_not_encrypted(driver): assert response.status_code == 401 -def test_handle_set_handle_set_characteristics_encrypted_with_exception(driver): +def test_handle_set_handle_set_characteristics_encrypted_with_exception( + driver: AccessoryDriver, +): """Verify an encrypted set_characteristics.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -649,7 +911,7 @@ def _mock_failure(*_): assert b"-70402" in response.body -def test_handle_snapshot_encrypted_non_existant_accessory(driver): +def test_handle_snapshot_encrypted_non_existant_accessory(driver: AccessoryDriver): """Verify an encrypted snapshot with non-existant accessory.""" bridge = Bridge(driver, "Test Bridge") driver.add_accessory(bridge) @@ -664,7 +926,7 @@ def test_handle_snapshot_encrypted_non_existant_accessory(driver): handler.handle_resource() -def test_attempt_to_pair_when_already_paired(driver): +def test_attempt_to_pair_when_already_paired(driver: AccessoryDriver): """Verify we respond with unavailable if already paired.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -688,7 +950,7 @@ def test_attempt_to_pair_when_already_paired(driver): } -def test_handle_get_characteristics_encrypted(driver): +def test_handle_get_characteristics_encrypted(driver: AccessoryDriver): """Verify an encrypted get_characteristics.""" acc = Accessory(driver, "TestAcc", aid=1) assert acc.aid == 1 @@ -725,7 +987,7 @@ def test_handle_get_characteristics_encrypted(driver): assert decoded_response["characteristics"][0]["status"] == -70402 -def test_invalid_pairing_two(driver): +def test_invalid_pairing_two(driver: AccessoryDriver): """Verify we respond with error with invalid request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -754,7 +1016,7 @@ def test_invalid_pairing_two(driver): } -def test_invalid_pairing_three(driver): +def test_invalid_pairing_three(driver: AccessoryDriver): """Verify we respond with error with invalid request.""" driver.add_accessory(Accessory(driver, "TestAcc")) diff --git a/tox.ini b/tox.ini index 1fc0e166..4e34d4a9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, py37, py38, py39, py310, docs, lint, pylint, bandit +envlist = py35, py36, py37, py38, py39, py310, py311, py312, docs, lint, pylint, bandit skip_missing_interpreters = True [gh-actions] @@ -10,7 +10,8 @@ python = 3.8: py38, mypy 3.9: py39, mypy 3.10: py310, mypy - 3.11: py310, mypy + 3.11: py311, mypy + 3.12: py312, mypy [testenv] deps =