From d0cc4ebeefaf1c5213fea63da4e389c436b9a263 Mon Sep 17 00:00:00 2001 From: Olivier Gimenez <176398299+ogimenez-wirepas@users.noreply.github.com> Date: Wed, 28 Aug 2024 19:50:18 +0200 Subject: [PATCH] Split network and node configuration --- examples/provisioning_config.yml | 73 +++++----- requirements.txt | 3 +- wirepas_provisioning_server/data.py | 130 ++++++++---------- wirepas_provisioning_server/helpers.py | 36 +++++ wirepas_provisioning_server/migrate_config.py | 119 ++++++++++++++++ wirepas_provisioning_server/models.py | 30 ++++ 6 files changed, 289 insertions(+), 102 deletions(-) create mode 100644 wirepas_provisioning_server/helpers.py create mode 100644 wirepas_provisioning_server/migrate_config.py create mode 100644 wirepas_provisioning_server/models.py diff --git a/examples/provisioning_config.yml b/examples/provisioning_config.yml index becd038..b13d4d4 100644 --- a/examples/provisioning_config.yml +++ b/examples/provisioning_config.yml @@ -8,19 +8,24 @@ # takes precedence over the individual components. # Format is: -# -# UID : (mandatory) Ex: test_node +# +# version: 1 +# networks: +# network_name: (mandatory)(string) Ex: test_network +# address: (optional)(uint) Ex: 0x1012EE +# channel: (optional)(uint) Ex: 13 +# authentication_key : (mandatory)(16 bytes string) Ex: 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF +# encryption_key : (mandatory)(16 bytes string) Ex: 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF +# +# node_name : (mandatory)(string) Ex: test_node # method : (mandatory)(Unsecured:0, Secured:1, Extended UID:3) Ex: 0 -# factory_key : (only for secured method)(32 bytes string, [0:15 Auth key][16:31 Enc Key]) -# authentication_key : (mandatory)(16 bytes string) Ex: 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF -# encryption_key : (mandatory)(16 bytes string) Ex: 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF +# factory_key : (only for methods 1 and 3)(32 bytes string, [0:15 Auth key][16:31 Enc Key]) # node_uid : (optional)(16 bytes string) Ex: 0x7e 0x71 0xe5 0xd7 0x22 0xef 0x0f 0x4b 0xa8 0x7d 0x44 0xd4 0xe0 0xe5 0xb5 0x7d # node_uid_type : (optional)(1 byte string) Ex: 0x01 # node_authenticator_uid_type : (optional)(1 byte string) Ex: 0x01 # node_authenticator_uid : (optional)(16 bytes string) Ex: 0xb3 0x43 0x33 0x00 0x93 0x81 0x08 0x4a 0x8d 0xb3 0xaa 0x9e 0x53 0xd2 0x2a 0x1e # uid : (optional)(34 bytes string) Ex: 0x01 0xb3 0x43 0x33 0x00 0x93 0x81 0x08 0x4a 0x8d 0xb3 0xaa 0x9e 0x53 0xd2 0x2a 0x1e 0x01 0x7e 0x71 0xe5 0xd7 0x22 0xef 0x0f 0x4b 0xa8 0x7d 0x44 0xd4 0xe0 0xe5 0xb5 0x7d -# network_address : (optional)(uint) Ex: 0x1012EE -# network_channel : (optional)(uint) Ex: 13 +# network : (mandatory)(string) Ex: test_network # node_id : (optional)(uint) Ex: 0x11 # node_role : (optional)(1 byte string) Ex: 0x41 # user_specific : (optional) indexes [128:255] @@ -28,32 +33,36 @@ # 129 : 0x34 -test_node_extended: - method : 3 - node_uid: 0x7e 0x71 0xe5 0xd7 0x22 0xef 0x0f 0x4b 0xa8 0x7d 0x44 0xd4 0xe0 0xe5 0xb5 0x7d - node_uid_type: 0x01 +networks: + network_demo: + address: 1053422 + authentication_key: '0x0102030405060708090a0b0c0d0e0f10' + channel: 2 + encryption_key: '0x0102030405060708090a0b0c0d0e0f10' + network_prod: + address: 2243501 + authentication_key: '0x100f0e0d0c0b0a090807060504030201' + channel: 7 + encryption_key: '0x100f0e0d0c0b0a090807060504030201' +nodes: + test_node_extended: authenticator_uid: 0xb3 0x43 0x33 0x00 0x93 0x81 0x08 0x4a 0x8d 0xb3 0xaa 0x9e 0x53 0xd2 0x2a 0x1e - authenticator_uid_type: 0x01 - factory_key : 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F - network_address : 0x1012EE - network_channel : 2 - node_id : 0x11 - authentication_key: 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F 0x10 - encryption_key: 0x10 0x0F 0x0E 0x0D 0x0C 0x0B 0x0A 0x09 0x08 0x07 0x06 0x05 0x04 0x03 0x02 0x01 -test_node_secure: - method: 1 - uid: 0x58 0xc8 0x12 0xad 0x37 0xe8 0x36 0x4a 0xa1 0x1f 0x1c 0xbc 0x63 0x3e 0x8e 0x34 + authenticator_uid_type: 1 factory_key: 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F - network_address: 0x1012EE - network_channel: 2 - node_id: 0x11 - authentication_key: 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F 0x10 - encryption_key: 0x10 0x0F 0x0E 0x0D 0x0C 0x0B 0x0A 0x09 0x08 0x07 0x06 0x05 0x04 0x03 0x02 0x01 -test_node_insecure: + method: 3 + network: network_prod + node_id: 17 + node_uid: 0x7e 0x71 0xe5 0xd7 0x22 0xef 0x0f 0x4b 0xa8 0x7d 0x44 0xd4 0xe0 0xe5 0xb5 0x7d + node_uid_type: 1 + test_node_insecure: method: 0 + network: network_demo + node_id: 17 uid: 0x41 0xb1 0x85 0x7a 0x0f 0xb6 0xb1 0x48 0xa5 0xe4 0xb9 0xb6 0x03 0x53 0x1b 0x3b - network_address: 0x1012EE - network_channel: 2 - node_id: 0x11 - authentication_key: 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F 0x10 - encryption_key: 0x10 0x0F 0x0E 0x0D 0x0C 0x0B 0x0A 0x09 0x08 0x07 0x06 0x05 0x04 0x03 0x02 0x01 \ No newline at end of file + test_node_secure: + factory_key: 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0X09 0X0A 0X0B 0X0C 0X0D 0X0E 0X0F + method: 1 + network: network_prod + node_id: 17 + uid: 0x58 0xc8 0x12 0xad 0x37 0xe8 0x36 0x4a 0xa1 0x1f 0x1c 0xbc 0x63 0x3e 0x8e 0x34 +version: 1 diff --git a/requirements.txt b/requirements.txt index be37601..e7f4154 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ wirepas-mqtt-library==1.2.5 cbor2==5.6.4 PyYAML==6.0.1 -pycryptodome==3.20.0 \ No newline at end of file +pycryptodome==3.20.0 +pydantic==2.8.2 \ No newline at end of file diff --git a/wirepas_provisioning_server/data.py b/wirepas_provisioning_server/data.py index 228f466..4ebb213 100644 --- a/wirepas_provisioning_server/data.py +++ b/wirepas_provisioning_server/data.py @@ -11,9 +11,11 @@ import yaml import logging -from typing import Optional +from typing import Final, Optional +from wirepas_provisioning_server.helpers import convert_to_bytes, convert_to_int, ProvisioningDataException from wirepas_provisioning_server.message import ProvisioningMethod +from wirepas_provisioning_server.migrate_config import ConfigFileMigration def _generate_extended_uid( @@ -26,10 +28,10 @@ def _generate_extended_uid( Generate extended UID bytes """ - authenticator_uid_type = _convert_to_bytes(authenticator_uid_type_raw) - authenticator_uid = _convert_to_bytes(authenticator_uid_raw) - node_uid_type = _convert_to_bytes(node_uid_type_raw) - node_uid = _convert_to_bytes(node_uid_raw) + authenticator_uid_type = convert_to_bytes(authenticator_uid_type_raw) + authenticator_uid = convert_to_bytes(authenticator_uid_raw) + node_uid_type = convert_to_bytes(node_uid_type_raw) + node_uid = convert_to_bytes(node_uid_raw) def _any_is_not_bytes(*args: bytes | list[bytes]) -> bool: return any(not isinstance(arg, bytes) for arg in args) @@ -43,34 +45,6 @@ def _any_is_not_bytes(*args: bytes | list[bytes]) -> bool: return b"".join([authenticator_uid_type, authenticator_uid, node_uid_type, node_uid]) -def _convert_to_bytes(param_raw: bytes | int | str) -> bytes: - if isinstance(param_raw, str): - if param_raw.upper().startswith("0X"): - param_raw = param_raw.upper().replace("0X", "") - param = bytes.fromhex(param_raw) - else: - param = bytes(param_raw, "utf-8") - elif isinstance(param_raw, int): - param = param_raw.to_bytes(max(1, (param_raw.bit_length() + 7)) // 8, byteorder="big") - else: - param = param_raw - - return param - - -def _convert_to_int(param: int | str) -> int: - if isinstance(param, str): - param = int(param, 0) - - return param - - -class ProvisioningDataException(Exception): - """ - Wirepas Provisioning data generic Exception - """ - - class ProvisioningData(dict): # flake8: noqa: C901 def __init__(self, config: Optional[str] = None): @@ -78,81 +52,99 @@ def __init__(self, config: Optional[str] = None): super(ProvisioningData, self).__init__() if config is not None: + migration = ConfigFileMigration(config) + migration.update() + del migration + try: with open(config, "r") as ymlfile: cfg = yaml.safe_load(ymlfile) except yaml.YAMLError: raise ProvisioningDataException("Invalid data config file.") - for node in cfg: + if cfg.get("version") != 1: + raise ProvisioningDataException("Invalid data config file. Version must be 1") + + # Validate network parameters + for name, network in cfg["networks"].items(): + try: + for parameter in [ + "authentication_key", + "encryption_key", + ]: + network[parameter] + except KeyError as e: + raise ProvisioningDataException(f"Invalid data config file. Network {name} must include {str(e)}.") - if "method" not in cfg[node].keys(): - raise ProvisioningDataException(f"Invalid data config file. {node} must include method.") + for node_name, node_cfg in cfg["nodes"].items(): + if "network" not in node_cfg.keys(): + raise ProvisioningDataException(f"Invalid data config file. Node {node_name} must include network.") + network_name = node_cfg["network"] + + if "method" not in node_cfg.keys(): + raise ProvisioningDataException(f"Invalid data config file. Node {node_name} must include method.") provision_methods = [e.value for e in ProvisioningMethod] - if cfg[node]["method"] not in provision_methods: - raise ProvisioningDataException(f"Method must be one of {provision_methods}") + if node_cfg["method"] not in provision_methods: + raise ProvisioningDataException(f"Node method must be one of {provision_methods}") - if "uid" in cfg[node].keys(): - uid: str | int | bytes = cfg[node]["uid"] - elif cfg[node]["method"] == ProvisioningMethod.EXTENDED: + if "uid" in node_cfg.keys(): + uid: str | int | bytes = node_cfg["uid"] + elif node_cfg["method"] == ProvisioningMethod.EXTENDED: try: uid = _generate_extended_uid( - cfg[node]["authenticator_uid_type"], - cfg[node]["authenticator_uid"], - cfg[node]["node_uid_type"], - cfg[node]["node_uid"], + node_cfg["authenticator_uid_type"], + node_cfg["authenticator_uid"], + node_cfg["node_uid_type"], + node_cfg["node_uid"], ) except KeyError: - raise ProvisioningDataException(f"Invalid data config file. {node} must include UID information.") + raise ProvisioningDataException( + f"Invalid data config file. Node {node_name} must include UID information." + ) else: - raise ProvisioningDataException(f"Invalid data config file. {node} must include UID information") + raise ProvisioningDataException(f"Invalid data config file. Node {node_name} must include UID information") - if "network_address" in cfg[node].keys(): - network_address = _convert_to_int(cfg[node]["network_address"]) + if "network_address" in node_cfg.keys(): + network_address = convert_to_int(cfg["networks"][network_name]["address"]) else: network_address = None - if "network_channel" in cfg[node].keys(): - network_channel = _convert_to_int(cfg[node]["network_channel"]) + if "network_channel" in node_cfg.keys(): + network_channel = convert_to_int(cfg["networks"][network_name]["channel"]) else: network_channel = None - if "node_id" in cfg[node].keys(): - node_id = _convert_to_int(cfg[node]["node_id"]) + if "node_id" in node_cfg.keys(): + node_id = convert_to_int(node_cfg["node_id"]) else: node_id = None - if "node_role" in cfg[node].keys(): - node_role = _convert_to_bytes(cfg[node]["node_role"]) + if "node_role" in node_cfg.keys(): + node_role = convert_to_bytes(node_cfg["node_role"]) else: node_role = None - if "user_specific" in cfg[node].keys(): + if "user_specific" in node_cfg.keys(): user_specific = dict() - for k in cfg[node]["user_specific"]: + for k in node_cfg["user_specific"]: if k < 128 or k > 255: raise KeyError - user_specific[k] = cfg[node]["user_specific"][k] + user_specific[k] = node_cfg["user_specific"][k] else: user_specific = None - if "factory_key" in cfg[node].keys(): - factory_key = _convert_to_bytes(cfg[node]["factory_key"]) + if "factory_key" in node_cfg.keys(): + factory_key = convert_to_bytes(node_cfg["factory_key"]) else: factory_key = None - if "encryption_key" not in cfg[node].keys(): - raise ProvisioningDataException(f"Invalid data config file. {node} must include encryption_key.") - if "authentication_key" not in cfg[node].keys(): - raise ProvisioningDataException(f"Invalid data config file. {node} must include authentication_key.") - self.append( - _convert_to_bytes(uid), - cfg[node]["method"], - _convert_to_bytes(cfg[node]["encryption_key"]), - _convert_to_bytes(cfg[node]["authentication_key"]), + convert_to_bytes(uid), + node_cfg["method"], + convert_to_bytes(cfg["networks"][network_name]["encryption_key"]), + convert_to_bytes(cfg["networks"][network_name]["authentication_key"]), network_address, network_channel, node_id=node_id, diff --git a/wirepas_provisioning_server/helpers.py b/wirepas_provisioning_server/helpers.py new file mode 100644 index 0000000..30ff2fc --- /dev/null +++ b/wirepas_provisioning_server/helpers.py @@ -0,0 +1,36 @@ +""" + Provisioning helpers + =================== + + .. Copyright: + Copyright 2024 Wirepas Ltd under Apache License, Version 2.0. + See file LICENSE for full license details. +""" + + +class ProvisioningDataException(Exception): + """ + Wirepas Provisioning data generic Exception + """ + + +def convert_to_bytes(param_raw: bytes | int | str) -> bytes: + if isinstance(param_raw, str): + if param_raw.upper().startswith("0X"): + param_raw = param_raw.upper().replace("0X", "") + param = bytes.fromhex(param_raw) + else: + param = bytes(param_raw, "utf-8") + elif isinstance(param_raw, int): + param = param_raw.to_bytes(max(1, (param_raw.bit_length() + 7)) // 8, byteorder="big") + else: + param = param_raw + + return param + + +def convert_to_int(param: int | str) -> int: + if isinstance(param, str): + param = int(param, 0) + + return param diff --git a/wirepas_provisioning_server/migrate_config.py b/wirepas_provisioning_server/migrate_config.py new file mode 100644 index 0000000..34d6f49 --- /dev/null +++ b/wirepas_provisioning_server/migrate_config.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# Copyright 2024 Wirepas Ltd licensed under Apache License, Version 2.0 +# +# See file LICENSE for full license details. +# +# Converts a config file from the old format to the new + +from datetime import datetime +from typing import Final, Optional +from wirepas_provisioning_server.helpers import ProvisioningDataException, convert_to_bytes +from wirepas_provisioning_server.models import NetworkV1 +import argparse +import logging +import uuid +import yaml + +_LOGGER: Final = logging.getLogger(__name__) + + +class ConfigFileMigration: + def __init__(self, filename: str): + self.filename = filename + + _LOGGER.debug("Loading file: {}".format(self.filename)) + try: + with open(filename, "r") as file: + self._configuration: dict = yaml.safe_load(file) + except yaml.YAMLError: + raise ProvisioningDataException("Invalid data config file.") + self.version: Optional[int] = self._configuration.get("version", None) + + def update(self) -> None: + match self._configuration.get("version", None): + case None: + _LOGGER.info("Updating config file") + # Old config file should not use any version key + self._update_old_to_v1() + case 1: + _LOGGER.debug("Configuration file already to version 1") + case _: + raise ProvisioningDataException(f'Invalid data config file. Version {self._configuration["version"]}.') + + def _update_old_to_v1(self) -> None: + """ + Converts the old format (no version) to V1, split networks and devices. + """ + networks: list[NetworkV1] = [] + + # Backup the old configuration file + time_string = datetime.now().strftime("%y%m%d-%H%M%S.bak") + with open(f"{self.filename}-{time_string}.backup", "x") as file: + yaml.safe_dump(self._configuration, file, width=300, sort_keys=True) + + # Create a unique network list + for node_configuration in self._configuration.values(): + existing_network: Optional[NetworkV1] = None + address = node_configuration.get("network_address") + channel = node_configuration.get("network_channel") + authentication_key = convert_to_bytes(node_configuration["authentication_key"]) + encryption_key = convert_to_bytes(node_configuration["encryption_key"]) + + for network in networks: + # Detect if this network already exists + if ( + network.address == address + and network.channel == channel + and network.authentication_key == authentication_key + and network.encryption_key == encryption_key + ): + existing_network = network + break + + if existing_network is None: + existing_network = NetworkV1( + address=address, + channel=channel, + authentication_key=authentication_key, + encryption_key=encryption_key, + name=f"network_{uuid.uuid4()}", + ) + networks.append(existing_network) + + # Update the node configuration + node_configuration["network"] = existing_network.name + for key in ["network_address", "network_channel"]: + try: + del node_configuration[key] + except KeyError: + # Pass as those keys are optional + pass + del node_configuration["authentication_key"] + del node_configuration["encryption_key"] + + configuration = { + "version": 1, + "nodes": self._configuration, + "networks": { + network.name: network.model_dump(exclude={"name"}, exclude_defaults=True, mode="json") for network in networks + }, + } + + with open(self.filename, "w") as file: + yaml.safe_dump(configuration, file, sort_keys=True, width=300) + + self.version = 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(fromfile_prefix_chars="@") + parser.add_argument( + "--config", + type=str, + help='The path to your .yml config file: "examples/provisioning_config.yml"', + ) + args = parser.parse_args() + + logging.basicConfig(format="%(levelname)s %(asctime)s %(message)s", level=logging.DEBUG) + + ConfigFileMigration(args.config).update() diff --git a/wirepas_provisioning_server/models.py b/wirepas_provisioning_server/models.py new file mode 100644 index 0000000..e79dae0 --- /dev/null +++ b/wirepas_provisioning_server/models.py @@ -0,0 +1,30 @@ +""" + Provisioning data models + =================== + + .. Copyright: + Copyright 2024 Wirepas Ltd under Apache License, Version 2.0. + See file LICENSE for full license details. +""" + +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from wirepas_provisioning_server.helpers import convert_to_bytes + + +class NetworkV1(BaseModel): + """Holding network parameters for the V1 configuration file format.""" + + address: Optional[int] = None + channel: Optional[int] = None + authentication_key: bytes + encryption_key: bytes + name: str = Field(frozen=True) + + @field_validator("authentication_key", "encryption_key", mode="before") + @classmethod + def check_key(cls, key: bytes | int | str) -> bytes: + return convert_to_bytes(key) + + class Config: + json_encoders = {bytes: lambda value: f"0x{value.hex()}"}