Skip to content

crypto.decrypt_eax fails for counters >= 65536 (period exponent 15 index >= 2) #55

Description

@buckleypaul

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions