commit 82f912b0fd5e4a63b7faebddeb7f60f43a287fbb parent 87d7e6ced49ebd16a460259c92088304ac4cd46a Author: Stéphan Kochen <git@stephank.nl> Date: Thu, 03 Sep 2026 21:11:09 +0200 Support direct CSRmesh network keys
diff --git a/custom_components/inlite/const.py b/custom_components/inlite/const.py index 0928d17a73127e82dafc02859e71365e23ff0696..7011f0c28651be6465703f4248436144c12df0d0 100644 --- a/custom_components/inlite/const.py +++ b/custom_components/inlite/const.py @@ -7,6 +7,7 @@ # Config entry data keys CONF_GARDEN_ID = "garden_id" CONF_GARDEN_NAME = "garden_name" +CONF_NETWORK_KEY = "network_key" CONF_PASSWORD = "password" CONF_TRANSFORMERS = "transformers" diff --git a/custom_components/inlite/coordinator.py b/custom_components/inlite/coordinator.py index be333f9cd70b4afd540933e8f21a2f099b6e841f..3d087b63731e320cdb88789b9f00556f2aadcece 100644 --- a/custom_components/inlite/coordinator.py +++ b/custom_components/inlite/coordinator.py @@ -24,6 +24,7 @@ from .const import ( BLE_LOCAL_NAME, CONF_IDLE_DISCONNECT, + CONF_NETWORK_KEY, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TRANSFORMERS, @@ -75,12 +76,15 @@ self._idle_disconnect_seconds = entry.options.get( CONF_IDLE_DISCONNECT, DEFAULT_IDLE_DISCONNECT_SECONDS ) - password = entry.data[CONF_PASSWORD] + password = entry.data.get(CONF_PASSWORD) + network_key_hex = entry.data.get(CONF_NETWORK_KEY) + network_key = bytes.fromhex(network_key_hex) if network_key_hex else None for tx_data in entry.data[CONF_TRANSFORMERS]: device_id = tx_data["device_id"] hub = InliteHub( device_id=device_id, passphrase=password, + network_key=network_key, on_state_update=self._handle_oob_state_update, on_teach_in_progress=self._handle_teach_in_progress, ) diff --git a/custom_components/inlite/lib/inlite_ble/crypto.py b/custom_components/inlite/lib/inlite_ble/crypto.py index ddbb41e2ad9bc41ca86f5e393ef3bc50174aeed8..2a2f83b6e1291743339cc11a2fdee4ca3df8b74e 100644 --- a/custom_components/inlite/lib/inlite_ble/crypto.py +++ b/custom_components/inlite/lib/inlite_ble/crypto.py @@ -16,10 +16,21 @@ Handles packet encryption, decryption, checksum computation, and sequence number management for the in-lite BLE mesh protocol. """ - def __init__(self, passphrase: str) -> None: + def __init__( + self, passphrase: str | None = None, network_key: bytes | None = None + ) -> None: + """Create crypto from either a mesh passphrase or its direct key.""" + if (passphrase is None) == (network_key is None): + raise ValueError("provide exactly one of passphrase or network_key") + if network_key is not None and len(network_key) != 16: + raise ValueError("network_key must be exactly 16 bytes") self._tx_seq_nr = secrets.randbelow(0xFFFFFF) self._controller_address = 0x8000 + secrets.randbelow(0xFFFD - 0x8000) - self._enc_key = self._derive_key(passphrase) + self._enc_key = ( + bytes(network_key) + if network_key is not None + else self._derive_key(passphrase) + ) @property def controller_address(self) -> int: diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index d16f3e940b5f504f598b9ee53ea98534a2ead967..7064a5766295daca484a26d92e486d89593bdac7 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -97,7 +97,9 @@ is the mesh destination address for a specific transformer. Args: device_id: The hub's mesh device ID (from cloud API transformers[].deviceId) - passphrase: The garden's network passphrase (from cloud API gardens[].password) + passphrase: The mesh passphrase, from which the network key is derived. + network_key: The already-derived 16-byte CSRmesh key. Mutually exclusive + with ``passphrase``; useful when joining a mesh without its passphrase. ble_address: BLE device address or name (e.g., 'inlitebt' or a MAC/UUID) on_state_update: Optional callback invoked when OOB broadcast updates zone states. """ @@ -105,13 +107,14 @@ def __init__( self, device_id: int, - passphrase: str, + passphrase: str | None = None, ble_address: str = "inlitebt", on_state_update: Callable[[], None] | None = None, on_teach_in_progress: Callable[[int, int], None] | None = None, + network_key: bytes | None = None, ) -> None: self._device_id = device_id - self._crypto = CsrMeshCrypto(passphrase) + self._crypto = CsrMeshCrypto(passphrase, network_key) self._ble_address = ble_address self._client: BleakClient | None = None self._loop: asyncio.AbstractEventLoop | None = None diff --git a/tests/test_crypto.py b/tests/test_crypto.py index f3aabc7c65f391126078d9f55d10ac064753e6c5..5907bac102557e42888bb707ef0220a4a08eabf1 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -6,12 +6,24 @@ class TestCsrMeshCrypto: """Tests for CsrMeshCrypto.""" - def test_passphrase_required(self) -> None: - """Passphrase must be provided (no default).""" - import inspect - sig = inspect.signature(CsrMeshCrypto.__init__) - param = sig.parameters["passphrase"] - assert param.default is inspect.Parameter.empty + def test_requires_exactly_one_credential_type(self) -> None: + """A derived passphrase and a direct key must not be mixed.""" + import pytest + + with pytest.raises(ValueError, match="exactly one"): + CsrMeshCrypto() + with pytest.raises(ValueError, match="exactly one"): + CsrMeshCrypto("test", b"\x00" * 16) + + def test_accepts_direct_16_byte_network_key(self) -> None: + key = CsrMeshCrypto.derive_key("test123") + assert CsrMeshCrypto(network_key=key)._enc_key == key + + def test_rejects_wrong_sized_network_key(self) -> None: + import pytest + + with pytest.raises(ValueError, match="16 bytes"): + CsrMeshCrypto(network_key=b"short") def test_key_derivation_deterministic(self) -> None: """Same passphrase should produce the same key."""