Skip to content

Commit

Permalink
Merge pull request #234 from ikalchev/v2.8.0
Browse files Browse the repository at this point in the history
V2.8.0
  • Loading branch information
ikalchev committed Apr 2, 2020
2 parents c8ffd07 + c81a5fb commit 4973c12
Show file tree
Hide file tree
Showing 10 changed files with 120 additions and 32 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@

CHAR_STAT_OK = 0
SERVICE_COMMUNICATION_FAILURE = -70402
SERVICE_CALLBACK = 0
SERVICE_CALLBACK_DATA = 1


def callback(func):
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion pyhap/characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion pyhap/const.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
54 changes: 33 additions & 21 deletions pyhap/hap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +30,8 @@

logger = logging.getLogger(__name__)

backend = default_backend()


# Various "tag" constants for HAP's TLV encoding.
class HAP_TLV_TAGS:
Expand Down Expand Up @@ -65,7 +69,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


Expand All @@ -76,7 +81,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):
Expand Down Expand Up @@ -313,8 +325,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))
Expand Down Expand Up @@ -379,9 +391,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)
Expand Down Expand Up @@ -443,9 +455,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())
Expand All @@ -461,8 +473,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))
Expand Down Expand Up @@ -683,10 +695,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

Expand Down Expand Up @@ -730,7 +742,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
Expand All @@ -747,9 +759,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
Expand All @@ -776,10 +788,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
Expand Down
4 changes: 3 additions & 1 deletion pyhap/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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."""
Expand All @@ -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):
Expand Down
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
curve25519-donna
ed25519
pycryptodome
tlslite-ng
cryptography
zeroconf
3 changes: 1 addition & 2 deletions requirements_all.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
base36
curve25519-donna
ed25519
pycryptodome
cryptography
pyqrcode
tlslite-ng
zeroconf
3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
REQUIRES = [
'curve25519-donna',
'ed25519',
'pycryptodome',
'tlslite-ng',
'cryptography',
'zeroconf',
]

Expand Down
49 changes: 47 additions & 2 deletions tests/test_accessory_driver.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 4973c12

Please sign in to comment.