From 8117856c2734b6ab06321997ca357b7cd1b0901d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Mar 2020 05:12:09 +0000 Subject: [PATCH 1/3] Service callback for set_characteristics For some services we want to send all the char value changes at once. This resolves an issue where we send ON and then BRIGHTNESS and the light would go to 100% and then dim to the brightness because each callback would only send one char at a time. --- pyhap/accessory_driver.py | 23 ++++++++++++++++ pyhap/characteristic.py | 3 ++- pyhap/service.py | 4 ++- tests/test_accessory_driver.py | 49 ++++++++++++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index dfe6163e..6a4161e8 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -54,6 +54,8 @@ CHAR_STAT_OK = 0 SERVICE_COMMUNICATION_FAILURE = -70402 +SERVICE_CALLBACK = 0 +SERVICE_CALLBACK_DATA = 1 def callback(func): @@ -633,6 +635,7 @@ def set_characteristics(self, chars_query, client_addr): :type chars_query: dict """ # TODO: Add support for chars that do no support notifications. + service_callbacks = {} for cq in chars_query[HAP_REPR_CHARS]: aid, iid = cq[HAP_REPR_AID], cq[HAP_REPR_IID] char = self.accessory.get_characteristic(aid, iid) @@ -647,6 +650,26 @@ def set_characteristics(self, chars_query, client_addr): if HAP_REPR_VALUE in cq: # TODO: status needs to be based on success of set_value char.client_update_value(cq[HAP_REPR_VALUE], client_addr) + # For some services we want to send all the char value + # changes at once. This resolves an issue where we send + # ON and then BRIGHTNESS and the light would go to 100% + # and then dim to the brightness because each callback + # would only send one char at a time. + service = char.service + + if service and service.setter_callback: + service_callbacks.setdefault( + service.display_name, + [service.setter_callback, {}] + ) + service_callbacks[service.display_name][ + SERVICE_CALLBACK_DATA + ][char.display_name] = cq[HAP_REPR_VALUE] + + for service_name in service_callbacks: + service_callbacks[service_name][SERVICE_CALLBACK]( + service_callbacks[service_name][SERVICE_CALLBACK_DATA] + ) def signal_handler(self, _signal, _frame): """Stops the AccessoryDriver for a given signal. diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index 881a87ba..bdd2888f 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -80,7 +80,7 @@ class Characteristic: """ __slots__ = ('broker', 'display_name', 'properties', 'type_id', - 'value', 'getter_callback', 'setter_callback') + 'value', 'getter_callback', 'setter_callback', 'service') def __init__(self, display_name, type_id, properties): """Initialise with the given properties. @@ -103,6 +103,7 @@ def __init__(self, display_name, type_id, properties): self.value = self._get_default_value() self.getter_callback = None self.setter_callback = None + self.service = None def __repr__(self): """Return the representation of the characteristic.""" diff --git a/pyhap/service.py b/pyhap/service.py index c68686db..f2c2d1f1 100644 --- a/pyhap/service.py +++ b/pyhap/service.py @@ -14,7 +14,7 @@ class Service: """ __slots__ = ('broker', 'characteristics', 'display_name', 'type_id', - 'linked_services', 'is_primary_service') + 'linked_services', 'is_primary_service', 'setter_callback') def __init__(self, type_id, display_name=None): """Initialize a new Service object.""" @@ -24,6 +24,7 @@ def __init__(self, type_id, display_name=None): self.display_name = display_name self.type_id = type_id self.is_primary_service = None + self.setter_callback = None def __repr__(self): """Return the representation of the service.""" @@ -43,6 +44,7 @@ def add_characteristic(self, *chars): for char in chars: if not any(char.type_id == original_char.type_id for original_char in self.characteristics): + char.service = self self.characteristics.append(char) def get_characteristic(self, name): diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 74c66dbd..00849662 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1,11 +1,22 @@ """Tests for pyhap.accessory_driver.""" import tempfile -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from uuid import uuid1 import pytest -from pyhap.accessory import Accessory, STANDALONE_AID +from pyhap.accessory import STANDALONE_AID, Accessory from pyhap.accessory_driver import AccessoryDriver +from pyhap.characteristic import (HAP_FORMAT_INT, HAP_PERMISSION_READ, + PROP_FORMAT, PROP_PERMISSIONS, + Characteristic) +from pyhap.const import HAP_REPR_IID, HAP_REPR_CHARS, HAP_REPR_AID, HAP_REPR_VALUE +from pyhap.service import Service + +CHAR_PROPS = { + PROP_FORMAT: HAP_FORMAT_INT, + PROP_PERMISSIONS: HAP_PERMISSION_READ, +} @pytest.fixture @@ -43,6 +54,40 @@ def test_persist_load(): assert driver.state.public_key == pk +def test_service_callbacks(driver): + acc = Accessory(driver, 'TestAcc') + + service = Service(uuid1(), 'Lightbulb') + char_on = Characteristic('On', uuid1(), CHAR_PROPS) + char_brightness = Characteristic('Brightness', uuid1(), CHAR_PROPS) + + service.add_characteristic(char_on) + service.add_characteristic(char_brightness) + + mock_callback = MagicMock() + service.setter_callback = mock_callback + + acc.add_service(service) + driver.add_accessory(acc) + + char_on_iid = char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = char_brightness.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics({ + HAP_REPR_CHARS: [{ + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_on_iid, + HAP_REPR_VALUE: True + }, { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 88 + }] + }, "mock_addr") + + mock_callback.assert_called_with({'On': True, 'Brightness': 88}) + + def test_start_stop_sync_acc(driver): class Acc(Accessory): running = True From 52a1718fc78c39c17677dd2da922962e84ad42d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2020 19:24:01 +0000 Subject: [PATCH 2/3] Switch to cryptography to resolve performance concerns. --- pyhap/hap_server.py | 54 ++++++++++++++++++++++++++------------------ requirements.txt | 3 +-- requirements_all.txt | 3 +-- setup.py | 3 +-- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index 82ed9862..a379e7d6 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -16,9 +16,11 @@ import socketserver import threading -from tlslite.utils.chacha20_poly1305 import CHACHA20_POLY1305 -from Crypto.Protocol.KDF import HKDF -from Crypto.Hash import SHA512 +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes + import curve25519 import ed25519 @@ -28,6 +30,7 @@ logger = logging.getLogger(__name__) +backend = default_backend() # Various "tag" constants for HAP's TLV encoding. class HAP_TLV_TAGS: @@ -65,7 +68,8 @@ class HAP_OPERATION_CODE: class HAP_CRYPTO: HKDF_KEYLEN = 32 # bytes, length of expanded HKDF keys - HKDF_HASH = SHA512 # Hash function to use in key expansion + HKDF_HASH = hashes.SHA512() # Hash function to use in key expansion + TAG_LENGTH = 16 # ChaCha20Poly1305 tag length TLS_NONCE_LEN = 12 # bytes, length of TLS encryption nonce @@ -76,8 +80,14 @@ def _pad_tls_nonce(nonce, total_len=HAP_CRYPTO.TLS_NONCE_LEN): def hap_hkdf(key, salt, info): """Just a shorthand.""" - return HKDF(key, HAP_CRYPTO.HKDF_KEYLEN, salt, HAP_CRYPTO.HKDF_HASH, context=info) - + hkdf = HKDF( + algorithm=HAP_CRYPTO.HKDF_HASH, + length=HAP_CRYPTO.HKDF_KEYLEN, + salt=salt, + info=info, + backend=backend, + ) + return hkdf.derive(key) class UnprivilegedRequestException(Exception): pass @@ -313,8 +323,8 @@ def _pairing_three(self, tlv_objects): hkdf_enc_key = hap_hkdf(long_to_bytes(session_key), self.PAIRING_3_SALT, self.PAIRING_3_INFO) - cipher = CHACHA20_POLY1305(hkdf_enc_key, "python") - decrypted_data = cipher.open(self.PAIRING_3_NONCE, bytearray(encrypted_data), b"") + cipher = ChaCha20Poly1305(hkdf_enc_key) + decrypted_data = cipher.decrypt(self.PAIRING_3_NONCE, bytes(encrypted_data), b"") assert decrypted_data is not None dec_tlv_objects = tlv.decode(bytes(decrypted_data)) @@ -379,9 +389,9 @@ def _pairing_five(self, client_username, client_ltpk, encryption_key): HAP_TLV_TAGS.PUBLIC_KEY, server_public, HAP_TLV_TAGS.PROOF, server_proof) - cipher = CHACHA20_POLY1305(encryption_key, "python") + cipher = ChaCha20Poly1305(encryption_key) aead_message = bytes( - cipher.seal(self.PAIRING_5_NONCE, bytearray(message), b"")) + cipher.encrypt(self.PAIRING_5_NONCE, bytes(message), b"")) client_uuid = uuid.UUID(str(client_username, "utf-8")) should_confirm = self.accessory_handler.pair(client_uuid, client_ltpk) @@ -443,9 +453,9 @@ def _pair_verify_one(self, tlv_objects): message = tlv.encode(HAP_TLV_TAGS.USERNAME, mac, HAP_TLV_TAGS.PROOF, server_proof) - cipher = CHACHA20_POLY1305(output_key, "python") + cipher = ChaCha20Poly1305(output_key) aead_message = bytes( - cipher.seal(self.PVERIFY_1_NONCE, bytearray(message), b"")) + cipher.encrypt(self.PVERIFY_1_NONCE, bytes(message), b"")) data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b'\x02', HAP_TLV_TAGS.ENCRYPTED_DATA, aead_message, HAP_TLV_TAGS.PUBLIC_KEY, public_key.serialize()) @@ -461,8 +471,8 @@ def _pair_verify_two(self, tlv_objects): """ logger.debug("Pair verify [2/2]") encrypted_data = tlv_objects[HAP_TLV_TAGS.ENCRYPTED_DATA] - cipher = CHACHA20_POLY1305(self.enc_context["pre_session_key"], "python") - decrypted_data = cipher.open(self.PVERIFY_2_NONCE, bytearray(encrypted_data), b"") + cipher = ChaCha20Poly1305(self.enc_context["pre_session_key"]) + decrypted_data = cipher.decrypt(self.PVERIFY_2_NONCE, bytes(encrypted_data), b"") assert decrypted_data is not None # TODO: dec_tlv_objects = tlv.decode(bytes(decrypted_data)) @@ -683,10 +693,10 @@ def makefile(self, *args, **kwargs): def _set_ciphers(self): """Generate out/inbound encryption keys and initialise respective ciphers.""" outgoing_key = hap_hkdf(self.shared_key, self.CIPHER_SALT, self.OUT_CIPHER_INFO) - self.out_cipher = CHACHA20_POLY1305(outgoing_key, "python") + self.out_cipher = ChaCha20Poly1305(outgoing_key) incoming_key = hap_hkdf(self.shared_key, self.CIPHER_SALT, self.IN_CIPHER_INFO) - self.in_cipher = CHACHA20_POLY1305(incoming_key, "python") + self.in_cipher = ChaCha20Poly1305(incoming_key) # socket.socket interface @@ -730,7 +740,7 @@ def recv(self, buflen=1042, flags=0): # Init. info about the block we just started. # Note we are setting the total length to block_length + mac length self.curr_in_total = \ - struct.unpack("H", block_length_bytes)[0] + self.in_cipher.tagLength + struct.unpack("H", block_length_bytes)[0] + HAP_CRYPTO.TAG_LENGTH self.num_in_recv = 0 self.curr_in_block = b"" buflen -= self.LENGTH_LENGTH @@ -747,9 +757,9 @@ def recv(self, buflen=1042, flags=0): # We read a whole block. Decrypt it and append it to the result. nonce = _pad_tls_nonce(struct.pack("Q", self.in_count)) # Note we are removing the mac length from the total length - block_length = self.curr_in_total - self.in_cipher.tagLength - plaintext = self.in_cipher.open( - nonce, bytearray(self.curr_in_block), + block_length = self.curr_in_total - HAP_CRYPTO.TAG_LENGTH + plaintext = self.in_cipher.decrypt( + nonce, bytes(self.curr_in_block), struct.pack("H", block_length)) result += plaintext self.in_count += 1 @@ -776,10 +786,10 @@ def sendall(self, data, flags=0): while offset < total: length = min(total - offset, self.MAX_BLOCK_LENGTH) length_bytes = struct.pack("H", length) - block = bytearray(data[offset: offset + length]) + block = bytes(data[offset: offset + length]) nonce = _pad_tls_nonce(struct.pack("Q", self.out_count)) ciphertext = length_bytes \ - + self.out_cipher.seal(nonce, block, length_bytes) + + self.out_cipher.encrypt(nonce, block, length_bytes) offset += length self.out_count += 1 result += ciphertext diff --git a/requirements.txt b/requirements.txt index 46f2e2b7..298889fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ curve25519-donna ed25519 -pycryptodome -tlslite-ng +cryptography zeroconf diff --git a/requirements_all.txt b/requirements_all.txt index 29dea85c..d282f975 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,7 +1,6 @@ base36 curve25519-donna ed25519 -pycryptodome +cryptography pyqrcode -tlslite-ng zeroconf diff --git a/setup.py b/setup.py index 28dc4646..06d11014 100644 --- a/setup.py +++ b/setup.py @@ -26,8 +26,7 @@ REQUIRES = [ 'curve25519-donna', 'ed25519', - 'pycryptodome', - 'tlslite-ng', + 'cryptography', 'zeroconf', ] From c81a5fbfb173e9dcfdf7d8e8cd31ae48e510bc41 Mon Sep 17 00:00:00 2001 From: Ivan Kalchev Date: Thu, 2 Apr 2020 19:35:11 +0300 Subject: [PATCH 3/3] v2.8.0 --- CHANGELOG.md | 8 ++++++++ pyhap/const.py | 2 +- pyhap/hap_server.py | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d655cf..edc15db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ Sections ### Developers --> +## [2.8.0] - 2020-04-02 + +### Added +- Add support for service-level callbacks. You can now register a callback that will be called for all characteristics that belong to it. [#229](https://github.com/ikalchev/HAP-python/pull/229) + +### Fixed +- - Switch the symmetric cipher to use the cryptography module. This greatly improves performance. [#232](https://github.com/ikalchev/HAP-python/pull/232) + ## [2.7.0] - 2020-01-26 ### Added diff --git a/pyhap/const.py b/pyhap/const.py index 9aa8953b..96664d6d 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,6 +1,6 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 2 -MINOR_VERSION = 7 +MINOR_VERSION = 8 PATCH_VERSION = 0 __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index a379e7d6..d1dc896a 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -32,6 +32,7 @@ backend = default_backend() + # Various "tag" constants for HAP's TLV encoding. class HAP_TLV_TAGS: REQUEST_TYPE = b'\x00' @@ -69,7 +70,7 @@ class HAP_OPERATION_CODE: class HAP_CRYPTO: HKDF_KEYLEN = 32 # bytes, length of expanded HKDF keys HKDF_HASH = hashes.SHA512() # Hash function to use in key expansion - TAG_LENGTH = 16 # ChaCha20Poly1305 tag length + TAG_LENGTH = 16 # ChaCha20Poly1305 tag length TLS_NONCE_LEN = 12 # bytes, length of TLS encryption nonce @@ -89,6 +90,7 @@ def hap_hkdf(key, salt, info): ) return hkdf.derive(key) + class UnprivilegedRequestException(Exception): pass