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

feat: allow one failed decryption before reauth #76

Merged
merged 1 commit into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 23 additions & 3 deletions src/xiaomi_ble/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,10 @@ def __init__(self, bindkey: bytes | None = None) -> None:
# or encryption is not in use
self.bindkey_verified = False

# If True then the decryption has failed or has not been verified yet.
# If False then the decryption has succeeded.
self.decryption_failed = True

# If this is True, then we have not seen an advertisement with a payload
# Until we see a payload, we can't tell if this device is encrypted or not
self.pending = True
Expand Down Expand Up @@ -1739,10 +1743,15 @@ def _parse_xiaomi(
if payload_length < next_start:
# The payload segments are corrupted - if this is legacy encryption
# then the key is probably just wrong
# V4 encryption has an authentication tag, so we don't apply the
# V4/V5 encryption has an authentication tag, so we don't apply the
# same restriction there.
if self.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY:
self.bindkey_verified = False
if self.decryption_failed is True:
# we only ask for reautentification
# till the decryption has failed twice.
self.bindkey_verified = False
else:
self.decryption_failed = True
_LOGGER.debug(
"Invalid payload data length, payload: %s", payload.hex()
)
Expand Down Expand Up @@ -1893,7 +1902,12 @@ def _decrypt_mibeacon_v4_v5(
nonce, encrypted_payload + mic, associated_data
)
except InvalidTag as error:
self.bindkey_verified = False
if self.decryption_failed is True:
# we only ask for reautentification till
# the decryption has failed twice.
self.bindkey_verified = False
else:
self.decryption_failed = True
_LOGGER.warning("Decryption failed: %s", error)
_LOGGER.debug("mic: %s", mic.hex())
_LOGGER.debug("nonce: %s", nonce.hex())
Expand All @@ -1906,6 +1920,7 @@ def _decrypt_mibeacon_v4_v5(
to_mac(xiaomi_mac),
)
return None
self.decryption_failed = False
self.bindkey_verified = True
return decrypted_payload

Expand Down Expand Up @@ -1938,6 +1953,11 @@ def _decrypt_mibeacon_legacy(

assert cipher is not None # nosec
# decrypt the data
# note that V2/V3 encryption will often pass the decryption process with a
# wrong encryption key, resulting in useless data, and we won't be able
# to verify this, as V2/V3 encryption does not use a tag to verify
# the decrypted data. This will be filtered as wrong data length
# during the conversion of the payload to sensor data.
try:
decrypted_payload = cipher.decrypt(encrypted_payload)
except ValueError as error:
Expand Down
23 changes: 22 additions & 1 deletion tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def test_bindkey_wrong():
device = XiaomiBluetoothDeviceData(bindkey=bytes.fromhex(bindkey))
assert device.supported(advertisement)
assert not device.bindkey_verified
assert device.decryption_failed
assert device.update(advertisement) == SensorUpdate(
title="Motion Sensor C40F (RTCGQ02LM)",
devices={
Expand Down Expand Up @@ -250,8 +251,18 @@ def test_bindkey_verified_can_be_unset_v4():

device = XiaomiBluetoothDeviceData(bindkey=bytes.fromhex(bindkey))
device.bindkey_verified = True
device.decryption_failed = False

assert device.supported(advertisement)
# the first advertisement will fail decryption, but we don't ask to reauth yet
assert device.bindkey_verified
assert device.decryption_failed

data_string = b"XY\x8d\n\x18\x0f\xc4\xe0D\xefT|\xc2z\\\x03\xa1\x00\x00\x00y"
advertisement = bytes_to_service_info(data_string, address="54:EF:44:E0:C4:0F")
assert device.supported(advertisement)
# the second advertisement will fail decryption again, but now we ask to reauth
assert device.decryption_failed
assert not device.bindkey_verified


Expand All @@ -264,6 +275,7 @@ def test_bindkey_wrong_legacy():
device = XiaomiBluetoothDeviceData(bindkey=bytes.fromhex(bindkey))
assert device.supported(advertisement)
assert not device.bindkey_verified
assert device.decryption_failed
assert device.update(advertisement) == SensorUpdate(
title="Dimmer Switch 988B (YLKG07YL/YLKG08YL)",
devices={
Expand All @@ -288,7 +300,6 @@ def test_bindkey_wrong_legacy():
),
},
)

assert device.unhandled == {}


Expand All @@ -300,8 +311,18 @@ def test_bindkey_verified_can_be_unset_legacy():

device = XiaomiBluetoothDeviceData(bindkey=bytes.fromhex(bindkey))
device.bindkey_verified = True
device.decryption_failed = False

assert device.supported(advertisement)
# the first advertisement will fail decryption, but we don't ask to reauth yet
assert device.bindkey_verified
assert device.decryption_failed

data_string = b"X0\xb6\x03\xd3\x8b\x98\xc5A$\xf8\xc3I\x14vu~\x00\x00\x00\x99"
advertisement = bytes_to_service_info(data_string, address="F8:24:41:C5:98:8B")
assert device.supported(advertisement)
# the second advertisement will fail decryption again, but now we ask to reauth
assert device.decryption_failed
assert not device.bindkey_verified


Expand Down
Loading