Summary
crypto.decrypt_eax() cannot decrypt an AES-EAX (protocol v2) packet whose time counter is >= 65536, even with the correct key. It returns None.
The counter tried for slot i is i * 2**period_exponent, so the failure starts at slot index 2**(16 - period_exponent):
| period_exponent |
slots that decrypt (of 128) |
first failing index |
| 15 (cloud default, ~9h) |
2 (indices 0-1) |
2 |
| 14 |
4 |
4 |
| 12 |
16 |
16 |
| 10 |
64 |
64 |
| 0-9 |
128 |
none (max counter 127*512 = 65024 < 65536) |
With the cloud's default exponent of 15, an EAX DEVICE_UPTIME device can only be decrypted locally during its first ~18 hours of uptime (slots 0 and 1). After that, every packet fails.
Root cause
src/hubblenetwork/crypto.py: decrypt_eax derives the EID intermediate key key_0 once, from counter 0, and reuses it for every candidate counter:
# crypto.py:108-110
# key_0 is constant when high counter bytes are 00 00 (counter < 65536)
key_0 = _derive_eid_key(key, 0)
ecb = AES.new(key_0, AES.MODE_ECB)
_derive_eid_key (crypto.py:70-78) puts the high two counter bytes into the key-derivation block, so key_0 differs for every 65536-counter block:
counter_bytes = counter.to_bytes(4, "little")
high_counter_bytes = bytes(reversed(counter_bytes[2:4]))
msg1 = b"\x00" * 11 + b"\xff" + b"\x00\x00" + high_counter_bytes
_generate_eid (crypto.py:81-87) calls _derive_eid_key(key, counter) with the real counter. decrypt_eax's inline EID computation (crypto.py:112-116) does not. The two agree only while counter < 65536. The comment at line 108 states that assumption, but the loop reaches 127 * 2**period_exponent, which is far past 65536 for any exponent >= 10. Also, the _derive_eid_key docstring says the result is constant "for counters 0-127", which only held before counters became multiples of 2**period_exponent.
Existing tests don't catch this: tests/test_aes_eax.py::_build_eax_packet_at_exponent always uses slot 3 with exponents <= 12, so its counter never exceeds 12288.
Minimal repro
Self-contained, uses a random key:
import os
from Crypto.Cipher import AES
from hubblenetwork.crypto import _generate_eid, decrypt_eax
from hubblenetwork.packets import AesEaxPacket, Location
key = os.urandom(16)
def build(counter, e):
eid = _generate_eid(key, counter, e)
salt = b"\x12\x34"
nonce = counter.to_bytes(4, "big") + salt
ct, tag = AES.new(key, AES.MODE_EAX, mac_len=4, nonce=nonce).encrypt_and_digest(b"hello")
return AesEaxPacket(timestamp=0, location=Location(lat=0, lon=0, fake=True),
protocol_version=2, nonce_salt=salt, eid=eid,
payload=ct, auth_tag=tag, rssi=-60)
e = 15
for i in range(4):
r = decrypt_eax(key, build(i << e, e), period_exponent=e)
print(i, i << e, r.payload if r else None)
Output:
0 0 b'hello'
1 32768 b'hello'
2 65536 None
3 98304 None
Expected vs actual
- Expected:
decrypt_eax(key, pkt, period_exponent=e) decrypts any packet whose counter is i * 2**e for i in range(pool_size), which is what the docstring promises.
- Actual: it returns
None for every counter >= 65536. The candidate EID is computed with the wrong key_0, so it never matches pkt.eid and the AES-EAX decrypt step never runs.
Impact
Every local decrypt path for AES-128-EAX DEVICE_UPTIME devices with period_exponent >= 10 (including the default of 15) is affected once the device's counter passes 65536:
hubblenetwork ble scan --key ...: packets show as failed or are hidden.
hubblenetwork ble detect: EaxExponentDetector sweeps exponents through decrypt_eax, so detection can fail or pick the wrong exponent after the first ~18h of uptime.
hubblenetwork ble validate: reports a valid key as not decrypting.
Cloud-side decryption is not affected. This is a local SDK bug only.
Suggested fix
Derive key_0 from the actual counter, or cache one per high-16-bit block:
step = 1 << period_exponent
ecbs: dict[int, AES] = {}
for i in range(pool_size):
counter = i * step
hi = counter >> 16
ecb = ecbs.get(hi)
if ecb is None:
ecb = ecbs[hi] = AES.new(_derive_eid_key(key, counter), AES.MODE_ECB)
...
The simplest alternative is to call _generate_eid(key, counter, period_exponent) directly, so there is a single EID implementation. Also:
- Fix the comment at crypto.py:108 and the
_derive_eid_key docstring.
- Add a regression test that round-trips slots 0-127 at
period_exponent=15, or at least slot 2 and slot 127.
Summary
crypto.decrypt_eax()cannot decrypt an AES-EAX (protocol v2) packet whose time counter is >= 65536, even with the correct key. It returnsNone.The counter tried for slot
iisi * 2**period_exponent, so the failure starts at slot index2**(16 - period_exponent):With the cloud's default exponent of 15, an EAX DEVICE_UPTIME device can only be decrypted locally during its first ~18 hours of uptime (slots 0 and 1). After that, every packet fails.
Root cause
src/hubblenetwork/crypto.py:decrypt_eaxderives the EID intermediate keykey_0once, from counter 0, and reuses it for every candidate counter:_derive_eid_key(crypto.py:70-78) puts the high two counter bytes into the key-derivation block, sokey_0differs for every 65536-counter block:_generate_eid(crypto.py:81-87) calls_derive_eid_key(key, counter)with the real counter.decrypt_eax's inline EID computation (crypto.py:112-116) does not. The two agree only whilecounter < 65536. The comment at line 108 states that assumption, but the loop reaches127 * 2**period_exponent, which is far past 65536 for any exponent >= 10. Also, the_derive_eid_keydocstring says the result is constant "for counters 0-127", which only held before counters became multiples of2**period_exponent.Existing tests don't catch this:
tests/test_aes_eax.py::_build_eax_packet_at_exponentalways uses slot 3 with exponents <= 12, so its counter never exceeds 12288.Minimal repro
Self-contained, uses a random key:
Output:
Expected vs actual
decrypt_eax(key, pkt, period_exponent=e)decrypts any packet whose counter isi * 2**eforiinrange(pool_size), which is what the docstring promises.Nonefor every counter >= 65536. The candidate EID is computed with the wrongkey_0, so it never matchespkt.eidand the AES-EAX decrypt step never runs.Impact
Every local decrypt path for AES-128-EAX DEVICE_UPTIME devices with
period_exponent >= 10(including the default of 15) is affected once the device's counter passes 65536:hubblenetwork ble scan --key ...: packets show as failed or are hidden.hubblenetwork ble detect:EaxExponentDetectorsweeps exponents throughdecrypt_eax, so detection can fail or pick the wrong exponent after the first ~18h of uptime.hubblenetwork ble validate: reports a valid key as not decrypting.Cloud-side decryption is not affected. This is a local SDK bug only.
Suggested fix
Derive
key_0from the actual counter, or cache one per high-16-bit block:The simplest alternative is to call
_generate_eid(key, counter, period_exponent)directly, so there is a single EID implementation. Also:_derive_eid_keydocstring.period_exponent=15, or at least slot 2 and slot 127.