Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ble pairing strategy #222

Merged
merged 20 commits into from
Feb 4, 2022
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions homekit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@
'Controller', 'BluetoothAdapterError', 'AccessoryDisconnectedError', 'AccessoryNotFoundError',
'AlreadyPairedError', 'AuthenticationError', 'BackoffError', 'BusyError', 'CharacteristicPermissionError',
'ConfigLoadingError', 'ConfigSavingError', 'ConfigurationError', 'FormatError', 'HomeKitException',
'HttpException', 'IncorrectPairingIdError', 'InvalidAuthTagError', 'InvalidError', 'InvalidSignatureError',
'MaxPeersError', 'MaxTriesError', 'ProtocolError', 'RequestRejected', 'UnavailableError', 'UnknownError',
'UnpairedError'
'HttpException', 'IncorrectPairingIdError', 'PairingAuthError', 'InvalidAuthTagError', 'InvalidError',
'InvalidSignatureError', 'MaxPeersError', 'MaxTriesError', 'ProtocolError', 'RequestRejected', 'UnavailableError',
'UnknownError', 'UnpairedError'
]

from homekit.controller import Controller
from homekit.exceptions import BluetoothAdapterError, AccessoryDisconnectedError, AccessoryNotFoundError, \
AlreadyPairedError, AuthenticationError, BackoffError, BusyError, CharacteristicPermissionError, \
ConfigLoadingError, ConfigSavingError, ConfigurationError, FormatError, HomeKitException, HttpException, \
IncorrectPairingIdError, InvalidAuthTagError, InvalidError, InvalidSignatureError, MaxPeersError, MaxTriesError, \
ProtocolError, RequestRejected, UnavailableError, UnknownError, UnpairedError
IncorrectPairingIdError, PairingAuthError, InvalidAuthTagError, InvalidError, InvalidSignatureError, \
MaxPeersError, MaxTriesError, ProtocolError, RequestRejected, UnavailableError, UnknownError, UnpairedError

from homekit.tools import IP_TRANSPORT_SUPPORTED

Expand Down
16 changes: 13 additions & 3 deletions homekit/controller/ble_impl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,10 +592,15 @@ def find_characteristic_by_uuid(device, service_uuid, char_uuid):
if ServicesTypes.get_short(service_uuid.upper()) == ServicesTypes.get_short(possible_service.uuid.upper()):
service_found = possible_service
break
logger.debug('searched service: %s', service_found)
logger.debug('searched service: %s', service_found.uuid)

if not service_found:
logging.error('searched service not found.')
try:
srv_desc = f'{Service[service_uuid]} '
except KeyError:
srv_desc = ''

logging.error(f'searched service %snot found.', srv_desc)
return None, None

result_char = None
Expand All @@ -612,7 +617,12 @@ def find_characteristic_by_uuid(device, service_uuid, char_uuid):
result_char_id = cid

if not result_char:
logging.error('searched char not found.')
try:
char_desc = f'{CharacteristicsTypes[char_uuid]} '
except KeyError:
char_desc = ''

logging.error('searched char %snot found.', char_desc)
return None, None

logger.debug('searched char: %s %s', result_char, result_char_id)
Expand Down
12 changes: 9 additions & 3 deletions homekit/controller/ble_impl/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,20 @@ def connect(self):
except dbus.exceptions.DBusException:
raise AccessoryNotFoundError('Unable to resolve device services + characteristics')

def characteristic_read_value_succeeded(self, characteristic):
logger.debug('read success: %s %s', str(characteristic.uuid))

def characteristic_read_value_failed(self, characteristic, error):
logger.debug('read failed: %s %s', characteristic, error)
logger.debug('read failed: %s %s', str(characteristic.uuid), error)

def characteristic_write_value_succeeded(self, characteristic):
logger.debug('write success: %s', characteristic)
logger.debug('write success: %s', str(characteristic.uuid))

def characteristic_write_value_failed(self, characteristic, error):
logger.debug('write failed: %s %s', characteristic, error)
logger.debug('write failed: %s %s', str(characteristic.uuid), error)

def descriptor_read_value_failed(self, descriptor, error):
logger.debug('read descriptor failed: %s %s', str(descriptor.uuid), error)


class DeviceManager(gatt.DeviceManager):
Expand Down
2 changes: 1 addition & 1 deletion homekit/controller/ble_impl/gatt.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def read_value(self, offset=0):
return val
except dbus.exceptions.DBusException as e:
error = _error_from_dbus_error(e)
self.service.device.descriptor_read_value_failed(self, error=error)
self.characteristic.service.device.descriptor_read_value_failed(self, error=error)


class Characteristic(gatt.Characteristic):
Expand Down
131 changes: 117 additions & 14 deletions homekit/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@
import re
import tlv8

from enum import IntEnum

from homekit.exceptions import AccessoryNotFoundError, ConfigLoadingError, UnknownError, \
AuthenticationError, ConfigSavingError, AlreadyPairedError, TransportNotSupportedError, MalformedPinError
from homekit.protocol import States, Methods, Errors, TlvTypes
AuthenticationError, ConfigSavingError, AlreadyPairedError, TransportNotSupportedError, \
MalformedPinError, PairingAuthError
from homekit.protocol import States, Methods, Errors, TlvTypes, FeatureFlags
from homekit.http_impl import HomeKitHTTPConnection
from homekit.protocol.statuscodes import HapStatusCodes
from homekit.protocol import perform_pair_setup_part1, perform_pair_setup_part2, create_ip_pair_setup_write
Expand All @@ -41,7 +44,7 @@
from .ble_impl.discovery import DiscoveryDeviceManager

if IP_TRANSPORT_SUPPORTED:
from homekit.zeroconf_impl import discover_homekit_devices, find_device_ip_and_port
from homekit.zeroconf_impl import discover_homekit_devices, find_device_ip_port_props
from homekit.controller.ip_implementation import IpPairing, IpSession


Expand All @@ -60,6 +63,17 @@ def __init__(self, ble_adapter='hci0'):
self.ble_adapter = ble_adapter
self.logger = logging.getLogger('homekit.controller.Controller')

class PairingAuth(IntEnum):
"""
Types of pairing authentication strategies
Auto: try pairing with hardware authentication , fall back to software authentication if necessary
HwAuth: only try hardware authentication
SwAuth: only try software authentication
"""
Auto = 0
HwAuth = 1
SwAuth = 2

@staticmethod
def discover(max_seconds=10):
"""
Expand Down Expand Up @@ -152,7 +166,7 @@ def identify(accessory_id):
"""
if not IP_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('IP')
connection_data = find_device_ip_and_port(accessory_id)
connection_data = find_device_ip_port_props(accessory_id)
if connection_data is None:
raise AccessoryNotFoundError('Cannot find accessory with id "{i}".'.format(i=accessory_id))

Expand Down Expand Up @@ -324,7 +338,34 @@ def check_pin_format(pin):
if not re.match(r'^\d\d\d-\d\d-\d\d\d$', pin):
raise MalformedPinError('The pin must be of the following XXX-XX-XXX where X is a digit between 0 and 9.')

def perform_pairing(self, alias, accessory_id, pin):
def _get_pair_method(self, auth_method: PairingAuth, feature_flags: int):
"""
Returns the used pairing method based on the supported features

A feature flag of 0 will be treated as pairing with software authentication support, to allow uncertified
accessories which are not MFi certified.

:param auth_method: user specified authentication method
:param feature_flags: represents the supported features. 0 is treated as "software authentication allowed".
:returns:
:raises PairingAuthError: if pairing authentication method is not supported
"""
pair_method = Methods.PairSetup

if auth_method == Controller.PairingAuth.Auto:
if feature_flags & FeatureFlags.AppleMFiCoprocessor:
pair_method = Methods.PairSetupWithAuth
elif auth_method == Controller.PairingAuth.HwAuth:
pair_method = Methods.PairSetupWithAuth
elif auth_method == Controller.PairingAuth.SwAuth:
pass
else:
raise PairingAuthError(f'auth_method: invalid value "{str(auth_method)}"')

logging.debug('using pairing method %s', pair_method)
return pair_method

def perform_pairing(self, alias, accessory_id, pin, auth_method=PairingAuth.HwAuth):
"""
This performs a pairing attempt with the IP accessory identified by its id.

Expand All @@ -338,6 +379,7 @@ def perform_pairing(self, alias, accessory_id, pin):
:param alias: the alias for the accessory in the controllers data
:param accessory_id: the accessory's id
:param pin: function to return the accessory's pin
:param auth_method: defines the used pairing auth_method
:raises AccessoryNotFoundError: if no accessory with the given id can be found
:raises AlreadyPairedError: if the alias was already used
:raises UnavailableError: if the device is already paired
Expand All @@ -347,12 +389,14 @@ def perform_pairing(self, alias, accessory_id, pin):
:raises MaxPeersError: if the device cannot accept an additional pairing
:raises UnavailableError: on wrong pin
:raises MalformedPinError: if the pin is malformed
:raises PairingAuthError: if pairing authentication method is not supported
"""
Controller.check_pin_format(pin)
finish_pairing = self.start_pairing(alias, accessory_id)

finish_pairing = self.start_pairing(alias, accessory_id, auth_method)
return finish_pairing(pin)

def start_pairing(self, alias, accessory_id):
def start_pairing(self, alias, accessory_id, auth_method=PairingAuth.Auto):
"""
This starts a pairing attempt with the IP accessory identified by its id.
It returns a callable (finish_pairing) which you must call with the pairing pin.
Expand All @@ -368,6 +412,7 @@ def start_pairing(self, alias, accessory_id):
:param alias: the alias for the accessory in the controllers data
:param accessory_id: the accessory's id
:param pin: function to return the accessory's pin
:param auth_method: defines the used pairing auth_method
:raises AccessoryNotFoundError: if no accessory with the given id can be found
:raises AlreadyPairedError: if the alias was already used
:raises UnavailableError: if the device is already paired
Expand All @@ -376,21 +421,24 @@ def start_pairing(self, alias, accessory_id):
:raises AuthenticationError: if the verification of the device's SRP proof fails
:raises MaxPeersError: if the device cannot accept an additional pairing
:raises UnavailableError: on wrong pin
:raises PairingAuthError: if pairing authentication method is not supported
"""
if not IP_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('IP')
if alias in self.pairings:
raise AlreadyPairedError('Alias "{a}" is already paired.'.format(a=alias))

connection_data = find_device_ip_and_port(accessory_id)
connection_data = find_device_ip_port_props(accessory_id)
if connection_data is None:
raise AccessoryNotFoundError('Cannot find accessory with id "{i}".'.format(i=accessory_id))
conn = HomeKitHTTPConnection(connection_data['ip'], port=connection_data['port'])

pair_method = self._get_pair_method(auth_method, connection_data['properties']['ff'])

try:
write_fun = create_ip_pair_setup_write(conn)

state_machine = perform_pair_setup_part1()
state_machine = perform_pair_setup_part1(pair_method)
request, expected = state_machine.send(None)
while True:
try:
Expand Down Expand Up @@ -426,7 +474,52 @@ def finish_pairing(pin):

return finish_pairing

def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0'):
def _read_feature_flags_ble(self, device) -> int:
"""helper function to read out the feature flags of the accessory
:param device: device connected to the accessory used for the request. Should be connected.
:return: 1 byte integer representing the feature flags
"""
pair_features_char, pair_features_char_id = find_characteristic_by_uuid(device,
ServicesTypes.PAIRING_SERVICE,
CharacteristicsTypes.PAIRING_FEATURES)
logging.debug('setup char: %s %s', str(pair_features_char.uuid), pair_features_char.service.device)

assert pair_features_char and pair_features_char_id, 'pairing feature characteristic not defined'

# prepare request
transaction_id = random.randrange(0, 255)
wdata = bytearray([0x00, HapBleOpCodes.CHAR_READ, transaction_id])
wdata.extend(pair_features_char_id.to_bytes(length=2, byteorder='little'))
logging.debug('sent %s', bytes(wdata).hex())

logging.debug('reading pairing features')

pair_features_char.write_value(value=wdata)
rdata = []
import time
while len(rdata) == 0:
time.sleep(1)
rdata = pair_features_char.read_value()

resp_data = [b for b in rdata]
expected_length = int.from_bytes(bytes(resp_data[3:5]), byteorder='little')

logging.debug(
'control field: {c:x}, tid: {t:x}, status: {s:x}, length: {length}'.format(c=resp_data[0], t=resp_data[1],
s=resp_data[2],
length=expected_length))
# 3 for uint8 TLV
assert expected_length == 3, 'invalid return length, expecting uint8'
assert transaction_id == resp_data[1], 'transaction id mismatch'
assert HapStatusCodes.SUCCESS == resp_data[2], f'hap status code: {HapStatusCodes[resp_data[2]]}'

resp_data = tlv8.decode(bytes([int(a) for a in resp_data[5:]]),
expected={AdditionalParameterTypes.Value: tlv8.DataType.INTEGER})
assert 0 < len(resp_data), 'received data is not an integer'

return resp_data.first_by_id(AdditionalParameterTypes.Value).data

def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0', auth_method=PairingAuth.Auto):
"""
This performs a pairing attempt with the Bluetooth LE accessory identified by its mac address.

Expand All @@ -442,14 +535,17 @@ def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0'):
:param accessory_mac: the accessory's mac address
:param pin: function to return the accessory's pin
:param adapter: the bluetooth adapter to be used (defaults to hci0)
:param auth_method: defines the used pairing auth_method
:raises MalformedPinError: if the pin is malformed
:raises PairingAuthError: if pairing authentication method is not supported
# TODO add raised exceptions
"""
Controller.check_pin_format(pin)
finish_pairing = self.start_pairing_ble(alias, accessory_mac, adapter)

finish_pairing = self.start_pairing_ble(alias, accessory_mac, adapter, auth_method)
return finish_pairing(pin)

def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
def start_pairing_ble(self, alias, accessory_mac, adapter='hci0', auth_method=PairingAuth.Auto):
"""
This starts a pairing attempt with the Bluetooth LE accessory identified by its mac address.
It returns a callable (finish_pairing) which you must call with the pairing pin.
Expand All @@ -464,6 +560,8 @@ def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
:param alias: the alias for the accessory in the controllers data
:param accessory_mac: the accessory's mac address
:param adapter: the bluetooth adapter to be used (defaults to hci0)
:param auth_method: defines the used pairing auth_method
:raises PairingAuthError: if pairing authentication method is not supported
# TODO add raised exceptions
"""
if not BLE_TRANSPORT_SUPPORTED:
Expand All @@ -479,13 +577,18 @@ def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
device.connect()
logging.debug('connected to device')

# --- read pairing features to determine if the authentication method is actually supported
feature_flags = self._read_feature_flags_ble(device)
pair_method = self._get_pair_method(auth_method, feature_flags)

# --- perform pairing
pair_setup_char, pair_setup_char_id = find_characteristic_by_uuid(device, ServicesTypes.PAIRING_SERVICE,
CharacteristicsTypes.PAIR_SETUP)
logging.debug('setup char: %s %s', pair_setup_char, pair_setup_char.service.device)
logging.debug('setup char: %s %s', str(pair_setup_char.uuid), pair_setup_char.service.device)

write_fun = create_ble_pair_setup_write(pair_setup_char, pair_setup_char_id)

state_machine = perform_pair_setup_part1()
state_machine = perform_pair_setup_part1(pair_method)
request, expected = state_machine.send(None)
while True:
try:
Expand Down
4 changes: 2 additions & 2 deletions homekit/controller/ip_implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from homekit.protocol import get_session_keys, create_ip_pair_verify_write
from homekit.protocol import States, Methods, Errors, TlvTypes
from homekit.model.characteristics import CharacteristicsTypes
from homekit.zeroconf_impl import find_device_ip_and_port
from homekit.zeroconf_impl import find_device_ip_port_props
from homekit.model.services import ServicesTypes


Expand Down Expand Up @@ -476,7 +476,7 @@ def __init__(self, pairing_data):
if not connected:
# no connection yet, so ip / port might have changed and we need to fall back to slow zeroconf lookup
device_id = pairing_data['AccessoryPairingID']
connection_data = find_device_ip_and_port(device_id)
connection_data = find_device_ip_port_props(device_id)

# update pairing data with the IP/port we elaborated above, perhaps next time they are valid
pairing_data['AccessoryIP'] = connection_data['ip']
Expand Down
9 changes: 8 additions & 1 deletion homekit/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# limitations under the License.
#


class HomeKitException(Exception):
"""Generic HomeKit exception.
Attributes:
Expand Down Expand Up @@ -66,6 +65,14 @@ class AuthenticationError(ProtocolError):
pass


class PairingAuthError(ProtocolError):
"""
Raised in on pairing if the current pairing method is not supported.
This can happen if an accessory does not support a specific authentication method.
"""
pass


class BackoffError(ProtocolError):
"""
Raised upon receipt of a back off error. It seems unclear when this is raised, must be related to
Expand Down
5 changes: 3 additions & 2 deletions homekit/model/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ class _FeatureFlags(object):

def __init__(self):
self._data = {
0: 'No support for HAP Pairing',
1: 'Supports HAP Pairing'
0x00: 'No support for HAP Pairing',
0x01: 'Supports HAP Pairing with Apple authentication coprocessor',
0x02: 'Supports HAP Pairing with Software authentication',
}

def __getitem__(self, item):
Expand Down
Loading