diff --git a/README.md b/README.md index 829a453..ab1b9fd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ HALDOR collects data from *doors*, *PIR*, and one-wire *Temperature Sensors.* +## Branch Description +This branch exists to edit the haldor GPIO data collection methods to include +the now-default and cross-platform `gpiod`. + ## Description HALDOR collects data from the sources listed above. The collected data is reported via MQTT in two different ways: interrupt and checkup. Interrupts are posted on `/event` and trigger automatically when any door or PIR changes state. Checkups are requested via `reporter/checkup_req` and include all sensors when posted on `/checkup`. On a configurable number of checkups, there is a long checkup which includes a configurable system information report. diff --git a/hdc.py b/hdc.py index 6ea87c4..35d3927 100755 --- a/hdc.py +++ b/hdc.py @@ -11,8 +11,20 @@ from typing import * from multitimer import MultiTimer from confirmation_threshold import confirmation_threshold +from maglab_crypto import MAGToken from threading import Event +def conv_value(myVal, trueVal, falseVal): + rValue = trueVal + if myVal == 0: + rValue = falseVal + if myVal == False: + rValue = falseVal + if type(myVal) == str: + if myVal.lower() == "off" or myVal.lower() == "false": + rValue = falseVal + return rValue + class HDCDaemon(Daemon): def run(self): h_datacollector = HDC() @@ -31,7 +43,7 @@ def run(self): class Acquisition: name: str acType: str - acObject: Union[List[str], int] + acObject: Union[List[str], int, List[int]] # state machine for temperature sensor power network restart # should probably add the state machine diagram in ascii art here @@ -98,44 +110,64 @@ class config: mqtt_port: int mqtt_timeout: int temp_max_restart: int = 3 + tokens: Optional[List[str]] = None loglevel: Optional[str] = None # overloaded MQTT functions from (mqtt.Client) def on_log(self, client, userdata, level, buff): if level == mqtt.MQTT_LOG_DEBUG: - logging.debug("PAHO MQTT DEBUG: " + buff) + self.log.debug("PAHO MQTT DEBUG: " + buff) elif level == mqtt.MQTT_LOG_INFO: - logging.info("PAHO MQTT INFO: " + buff) + self.log.info("PAHO MQTT INFO: " + buff) elif level == mqtt.MQTT_LOG_NOTICE: - logging.info("PAHO MQTT NOTICE: " + buff) + self.log.info("PAHO MQTT NOTICE: " + buff) elif level == mqtt.MQTT_LOG_WARNING: - logging.warning("PAHO MQTT WARN: " + buff) + self.log.warning("PAHO MQTT WARN: " + buff) else: - logging.error("PAHO MQTT ERROR: " + buff) + self.log.error("PAHO MQTT ERROR: " + buff) def on_connect(self, client, userdata, flags, rc): - logging.info("Connected: " + str(rc)) + self.log.info("Connected: " + str(rc)) self.subscribe("reporter/checkup_req") self.subscribe(self.config.name + "/temp_power") + self.subscribe(f"{self.config.name}/cmd") def on_message(self, client, userdata, message): if (message.topic == "reporter/checkup_req"): - logging.info("Checkup received.") + self.log.info("Checkup received.") self.checkup() elif (message.topic == self.config.name + "/temp_power"): decoded = message.payload.decode('utf-8') - logging.debug("Temperature sensor power command received: " + decoded) + self.log.debug("Temperature sensor power command received: " + decoded) if (decoded.lower() == "false" or decoded == "0"): - logging.info("Temperature sensor power commanded off") + self.log.info("Temperature sensor power commanded off") self.runtime.temp_power_commanded = False else: - logging.info("Temperature sensor power commanded on") + self.log.info("Temperature sensor power commanded on") self.runtime.temp_power_commanded = True + elif message.topic == f"{self.config.name}/cmd": + self.log.info(f"Received command: {message.payload.decode('utf-8')}") + if self.mag_token: + commands = self.mag_token.cmd_msg_auth(message.payload.decode("utf-8"), 7200) + if commands: + line_values = {} + for name, value in commands.items(): + if name == "temp_power": + self.log.debug(f"Temperature sensor power command received: {decoded}") + if name in self.runtime.output_channels.keys(): + output = conv_value(value, Value.ACTIVE, Value.INACTIVE) + channel = self.runtime.output_channels[name] + line_values.update({channel:output}) + self.runtime.output_values.update({name:output}) + self.log.info(f"{name} on {channel} set to {output}") + self._gpioreq.set_values(line_values) + + def on_disconnect(self, client, userdata, rc): - logging.warning("Disconnected: " + str(rc)) + self.log.warning("Disconnected: " + str(rc)) if rc != 0: - logging.error("Unexpected disconnection. Attempting reconnection.") + self.log.error("Unexpected disconnection. Attempting reconnection.") reconnect_count = 0 while (reconnect_count < 10): try: @@ -143,27 +175,24 @@ def on_disconnect(self, client, userdata, rc): self.reconnect() break except OSError: - logging.error("Connection error while trying to reconnect.") - logging.error(traceback.format_exc()) - logging.error("Waiting to restart.") + self.log.error("Connection error while trying to reconnect.") + self.log.error(traceback.format_exc()) + self.log.error("Waiting to restart.") self.tEvent.wait(30) if reconnect_count >= 10: - logging.critical("Too many reconnect tries. Exiting.") + self.log.critical("Too many reconnect tries. Exiting.") os._exit(1) # HDC functions def enable_gpio(self): - global GPIO - if not self.config.gpio_path: - logging.debug("Configuring GPIOs") - import orangepi.one - import OPi.GPIO as GPIO - GPIO.setmode(orangepi.one.BOARD) - GPIO.setwarnings(False) + global GPIO, Direction, Bias, Value + if self.config.gpio_path.startswith("/dev/gpiochip"): + self.log.debug("Configuring GPIOs at: " + self.config.gpio_path) + import gpiod as GPIO + from gpiod.line import Direction, Bias, Value + self._gpiodict = {} else: - logging.debug("Configuring GPIOs at: " + self.config.gpio_path) - import RPi.GPIO as GPIO - GPIO.setmode(GPIO.BCM) + raise KeyError("This HDC implementation does not support the selected GPIO method.") # sort GPIOs self.runtime = type("Runtime", (object, ), {}) @@ -174,25 +203,25 @@ def enable_gpio(self): self.runtime.ct_ios = {} self.runtime.temp_channels = {} self.runtime.temp_power_sm = {} + self.runtime.output_channels = {} + self.runtime.output_values = {} + self.log.debug("Running through I/O configuration.") for acq in self.config.acq_io: if acq.acType == "SW": - logging.debug("Configuring Switch: " + str(acq.acObject)) - GPIO.setup(acq.acObject, GPIO.IN, pull_up_down=GPIO.PUD_UP) + self.log.debug("Configuring Switch: " + str(acq.acObject)) self.runtime.switch_channels.update({acq.name : acq.acObject}) - self.runtime.ct_ios.update({acq.name : confirmation_threshold(GPIO.input(acq.acObject),3)}) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.INPUT, bias=Bias.PULL_UP)}) elif acq.acType == "SW_INV": - logging.debug("Configuring invSwitch: " + str(acq.acObject)) - GPIO.setup(acq.acObject, GPIO.IN, pull_up_down=GPIO.PUD_UP) + self.log.debug("Configuring invSwitch: " + str(acq.acObject)) self.runtime.flip_channels.update({acq.name : acq.acObject}) - self.runtime.ct_ios.update({acq.name : confirmation_threshold(not GPIO.input(acq.acObject),3)}) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.INPUT, bias=Bias.PULL_UP)}) elif acq.acType == "PIR": - logging.debug("Configuring PIR Sensor: " + str(acq.acObject)) - GPIO.setup(acq.acObject, GPIO.IN, pull_up_down=GPIO.PUD_UP) + self.log.debug("Configuring PIR Sensor: " + str(acq.acObject)) self.runtime.pir_channels.update({acq.name : acq.acObject}) - self.runtime.ct_ios.update({acq.name : confirmation_threshold(GPIO.input(acq.acObject),3)}) self.runtime.last_pir_state.update({acq.name : 0}) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.INPUT, bias=Bias.PULL_UP)}) elif acq.acType == "TEMP": - logging.debug("Configuring Temperature Sensor: " + str(acq.acObject)) + self.log.debug("Configuring Temperature Sensor: " + str(acq.acObject)) self.runtime.temp_channels.update({acq.name : acq.acObject}) self.runtime.temp_power_sm.update({acq.name : TempSensorPower(self.config.temp_max_restart)}) elif acq.acType == "TEMP_FAULT": @@ -200,36 +229,63 @@ def enable_gpio(self): self.runtime.temp_fault raise KeyError("Temperature sensor fault channel already allocated") except AttributeError: - logging.debug("Configuring Temperature Power Fault: " + str(acq.acObject)) + self.log.debug("Configuring Temperature Power Fault: " + str(acq.acObject)) self.runtime.temp_fault = acq.acObject - GPIO.setup(acq.acObject, GPIO.IN, pull_up_down=GPIO.PUD_UP) - self.runtime.temp_fault_sm = confirmation_threshold(not GPIO.input(acq.acObject),3) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.INPUT, bias=Bias.PULL_UP)}) + elif acq.acType == "OUT": + self.log.debug("Configuring Output: " + str(acq.acObject)) + try: + self.runtime.output_channels.update({acq.name : acq.acObject[0]}) + self.runtime.output_values.update({acq.name : conv_value(acq.acObject[1], Value.ACTIVE, Value.INACTIVE)}) + self._gpiodict.update({acq.acObject[0] : GPIO.LineSettings(direction=Direction.OUTPUT, output_value=conv_value(acq.acObject[1], Value.ACTIVE, Value.INACTIVE))}) + except TypeError: + self.runtime.output_channels.update({acq.name : acq.acObject}) + self.runtime.output_values.update({acq.name : Value.INACTIVE}) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.OUTPUT, output_value=Value.INACTIVE)}) elif acq.acType == "TEMP_EN": try: self.runtime.temp_en raise KeyError("Temperature sensor enable channel already allocated") except AttributeError: - logging.debug("Configuring Temperature Power Enable: " + str(acq.acObject)) + self.log.debug("Configuring Temperature Power Enable: " + str(acq.acObject)) self.runtime.temp_en = acq.acObject - GPIO.setup(acq.acObject, GPIO.OUT) + self._gpiodict.update({acq.acObject : GPIO.LineSettings(direction=Direction.OUTPUT)}) self.runtime.temp_power_commanded = True self.runtime.temp_power_on = True self.runtime.temp_power_last = True else: raise KeyError('"' + acq.acType + '"' + " is not a valid acquisition type") + self.log.debug(f"GPIO configuration generated: {self._gpiodict}") + self.log.debug("Applying configuration.") + self._gpioreq = GPIO.request_lines(self.config.gpio_path, consumer=f"{self.config.name}:hdc.py", config=self._gpiodict) + + self.log.debug("Starting debouncing.") + # Switches + for name, line in self.runtime.switch_channels.items(): + self.runtime.ct_ios.update({name : confirmation_threshold(1 if self._gpioreq.get_value(line) == Value.ACTIVE else 0, 3)}) + # Inverted Switches + for name, line in self.runtime.flip_channels.items(): + self.runtime.ct_ios.update({name : confirmation_threshold(0 if self._gpioreq.get_value(line) == Value.ACTIVE else 1, 3)}) + # PIR sensors + for name, line in self.runtime.pir_channels.items(): + self.runtime.ct_ios.update({name : confirmation_threshold(1 if self._gpioreq.get_value(line) == Value.ACTIVE else 0, 1)}) + # Temperature Fault + if hasattr(self.runtime, "temp_fault"): + self.runtime.temp_fault_sm = confirmation_threshold(0 if self._gpioreq.get_value(self.runtime.temp_en) == Value.ACTIVE else 1, 3) + def notify(self, path, params, retain=False): params['time'] = str(time.time()) - logging.debug(params) + self.log.debug(params) topic = self.config.name + '/' + path self.publish(topic, json.dumps(params), retain=retain) - logging.info("Published " + topic) + self.log.info("Published " + topic) def notify_bootup(self): boot_checks = {} - logging.debug("Bootup:") + self.log.debug("Bootup:") for bc_name, bc_cmd in self.config.boot_check_list.items(): boot_checks[bc_name] = subprocess.check_output( @@ -266,7 +322,7 @@ def check_temp(self, temp_path): def signal_handler(self, signum, frame): # so far, we only need to handle signals that make the program exit. - logging.warning("Caught a deadly signal: " + str(signum) + "!") + self.log.warning("Caught a deadly signal: " + str(signum) + "!") if self.ioPolling: self.ioPolling.stop() self.running = False @@ -310,12 +366,12 @@ def checkup(self): self.runtime.temp_power_on = self.runtime.temp_power_sm[ts_name].run(self.runtime.temp_power_last, self.runtime.temp_power_on, received, self.runtime.temp_power_fault) if self.runtime.temp_power_sm[ts_name].broke: if self.runtime.temp_power_sm[ts_name].state == TempSensorPower.PowerState.RESTART: - logging.warn("Temp sensor \"" + ts_name + "down and causing one-wire network restart!") + self.log.warn("Temp sensor \"" + ts_name + "down and causing one-wire network restart!") else: - logging.warn("Temp sensor \"" + ts_name + "down!") + self.log.warn("Temp sensor \"" + ts_name + "down!") self.runtime.temp_power_last = self.runtime.temp_power_on checks["Temp Power"] = int(self.runtime.temp_power_on) - GPIO.output(self.runtime.temp_en, self.runtime.temp_power_on) + self._gpioreq.set_value(self.runtime.temp_en, self.runtime.temp_power_on) self.notify('checkup', checks) @@ -326,27 +382,29 @@ def io_check(self): self.io_check_count = 0 else: self.io_check_count += 1 - logging.debug("IO check " + str(self.io_check_count)) + self.log.debug("IO check " + str(self.io_check_count)) for name, chan in self.runtime.switch_channels.items(): - result = self.runtime.ct_ios[name].update(GPIO.input(chan)) + result = self.runtime.ct_ios[name].update(1 if self._gpioreq.get_value(chan) == Value.ACTIVE else 0) # value confirmed if result[0]: checks[name] = result[1] for name, chan in self.runtime.flip_channels.items(): - result = self.runtime.ct_ios[name].update(int (not GPIO.input(chan))) + result = self.runtime.ct_ios[name].update(0 if self._gpioreq.get_value(chan) == Value.ACTIVE else 1) if result[0]: checks[name] = result[1] for name, chan in self.runtime.pir_channels.items(): - result = self.runtime.ct_ios[name].update(GPIO.input(chan)) + result = self.runtime.ct_ios[name].update(1 if self._gpioreq.get_value(chan) == Value.ACTIVE else 0) # PIR's are special because they like to be on and are only turned off during # timed checkups - if result[0] and result[1] and not self.runtime.last_pir_state[name]: + if result[0] and result[1] != self.runtime.last_pir_state[name]: checks[name] = result[1] self.runtime.last_pir_state[name] = result[1] - + for name, chan in self.runtime.output_channels.items(): + self.log.debug(f"IO {name} outputting value {self.runtime.output_values[name]}") + self._gpioreq.set_value(chan, self.runtime.output_values[name]) # don't run the temperature power control if there is no such thing. try: - result = self.runtime.temp_fault_sm.update(not GPIO.input(self.runtime.temp_fault)) + result = self.runtime.temp_fault_sm.update(0 if self._gpioreq.get_value(self.runtime.temp_fault) == Value.ACTIVE else 1) if result[0]: checks["Temp Power Fault"] = result[1] except AttributeError: @@ -355,21 +413,26 @@ def io_check(self): if checks: self.notify('event', checks) else: - logging.debug("Noting changed between timed io checks") + self.log.debug("Nothing changed between timed io checks") def run(self): + self.log = logging.getLogger(__name__) self.tEvent = Event() self.running = True startup_count = 0 self.io_check_count = 0 self.loop_count = 0 + self.mag_token = None try: if type(logging.getLevelName(self.config.loglevel.upper())) is int: logging.basicConfig(level=self.config.loglevel.upper()) else: - logging.warning("Log level not configured. Defaulting to WARNING.") + self.log.warning("Log level not configured. Defaulting to WARNING.") except (KeyError, AttributeError) as e: - logging.warning("Log level not configured. Defaulting to WARNING. Caught: " + str(e)) + self.log.warning("Log level not configured. Defaulting to WARNING. Caught: " + str(e)) + + if self.config.tokens: + self.mag_token = MAGToken(self.config.tokens) self.bootup() while startup_count < 10: @@ -379,21 +442,21 @@ def run(self): self.connect(self.config.mqtt_broker, self.config.mqtt_port, self.config.mqtt_timeout) atexit.register(self.disconnect) self.notify_bootup() - self.ioPolling = MultiTimer(interval=5, function=self.io_check) + self.ioPolling = MultiTimer(interval=1, function=self.io_check) self.ioPolling.start() atexit.register(self.ioPolling.stop) break except OSError: - logging.error("Error connecting on bootup.") - logging.error(traceback.format_exc()) - logging.error("Waiting to reconnect...") + self.log.error("Error connecting on bootup.") + self.log.error(traceback.format_exc()) + self.log.error("Waiting to reconnect...") self.tEvent.wait(30) if startup_count >= 10: - logging.critical("Too many startup tries. Exiting.") + self.log.critical("Too many startup tries. Exiting.") os._exit(1) - logging.info("Startup success.") + self.log.info("Startup success.") self.reconnect_me = False self.inner_reconnect_try = 0 while self.running and (self.inner_reconnect_try < 10): @@ -413,11 +476,11 @@ def run(self): except (socket.timeout, TimeoutError, ConnectionError): self.inner_reconnect_try += 1 self.reconnect_me = True - logging.error("MQTT loop error. Attempting to reconnect: " + inner_reconnect_try + "/10") + self.log.error("MQTT loop error. Attempting to reconnect: " + inner_reconnect_try + "/10") except: - logging.critical("Exception in MQTT loop.") - logging.critical(traceback.format_exc()) - logging.critical("Exiting.") + self.log.critical("Exception in MQTT loop.") + self.log.critical(traceback.format_exc()) + self.log.critical("Exiting.") exit(2) if self.inner_reconnect_try >= 10: exit(1) diff --git a/hdc_config.secmon00.json b/hdc_config.secmon00.json new file mode 100644 index 0000000..b192a1e --- /dev/null +++ b/hdc_config.secmon00.json @@ -0,0 +1,40 @@ +{ + "name": "secmon00", + "description": "This is the security monitor configuration file.", + "boot_check_list": { }, + "acq_io":[ + { + "name": "TestPIR0", + "acType": "PIR", + "acObject": 20 + }, + { + "name": "TestPIR1", + "acType": "PIR", + "acObject": 10 + }, + { + "name": "TestPIR2", + "acType": "PIR", + "acObject": 9 + }, + { + "name": "TestPIR3", + "acType": "PIR", + "acObject": 8 + }, + { + "name": "TestPIR4", + "acType": "PIR", + "acObject": 19 + } + ], + "long_checkup_freq": 100, + "long_checkup_leng": 0, + "pidfile":"/tmp/daisy.pid", + "gpio_path": "/dev/gpiochip0", + "mqtt_broker": "hal", + "mqtt_port": 1883, + "mqtt_timeout": 60, + "loglevel": "DEBUG" +} diff --git a/led_change.py b/led_change.py new file mode 100755 index 0000000..b33c568 --- /dev/null +++ b/led_change.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import logging +import json +import paho.mqtt.publish as publish +import paho.mqtt.client as mqtt +from maglab_crypto import MAGToken + +class LED_RELAY(mqtt.Client): + "Main class which toggles LEDs when PIR modules detect motion" + def __init__(self, token): + self.log = logging.getLogger(__name__) + self.token = token + + mqtt.Client.__init__(self, mqtt.CallbackAPIVersion.VERSION2) + + def on_log(self, _, __, level, buff): + """ Overloaded MQTT log function """ + if level == mqtt.MQTT_LOG_DEBUG: + self.log.debug(f"PAHO: {buff}") + elif level == mqtt.MQTT_LOG_INFO: + self.log.info(f"PAHO: {buff}") + elif level == mqtt.MQTT_LOG_NOTICE: + self.log.info(f"PAHO: {buff}") + else: + self.log.error(f"PAHO: {buff}") + + def on_connect(self, _, __, ___, reason, ____): + self.log.info(f"MQTT connected: {reason}") + self.subscribe("secmon00/+") + + def on_message(self, _, __, msg): + if msg.topic.startswith("secmon00/"): + out_d = {} + decoded = msg.payload.decode('utf-8') + self.log.debug(f"Motion message received: {decoded}") + try: + data = json.loads(decoded) + for i in range(5): + if f"TestPIR{i}" in data: + out_d.update({f"LEDPIR{i}" : data[f"TestPIR{i}"]}) + if len(out_d) > 0: + cmd_msg = str(MAGToken.cmd_msg_gen(out_d, self.token)) + self.publish("secmon00/cmd", cmd_msg) + except json.JSONDecodeError as exc: + self.log.info(str(exc)) + + def main(self): + self.connect("hal.maglab", 1883, 60) + self.loop_forever() + + +if __name__ == "__main__": + logging.basicConfig(level="DEBUG") + token = "magls_NXQmv+RixRJnH3gbUq2Ttp/85Zd9qantr7DrZQV6DMWw" + token = MAGToken.token_decode("magls_", token) + + relay = LED_RELAY(token) + relay.main() diff --git a/maglab_crypto/__init__.py b/maglab_crypto/__init__.py new file mode 100644 index 0000000..3c60626 --- /dev/null +++ b/maglab_crypto/__init__.py @@ -0,0 +1,156 @@ +# mag laboratory cryptography library +# currently only contains the token class + +import re +import json +import base64 +import zlib +import hashlib +import hmac +import time +import logging + +class MAGBase64: + @staticmethod + def b64enc(obj): + """ encode in base 64 (and without padding) """ + return base64.b64encode(obj).decode("utf-8").rstrip('=') + + @staticmethod + def b64pad(line): + """ pad for the python b64 library """ + num = (4 - len(line) % 4) % 4 + return f"{line}{'=' * num}" + + + +# class containing token utility +class MAGToken: + MINCTLEN = 2 # minimum central token length + B64CRCLEN = 6 # base 64 encoded CRC length + _tokens = [] + log = None + + def __init__(self, tokens, start = "magls_"): + self.log = logging.getLogger(__name__) + # set the prefix that we are supposed to decode + self.start = start + # call token decode function + self.tokens_decode(tokens) + + def tokens_decode(self, tokens): + idx = 0 + for token in tokens: + try: + self.log.debug(f"Decoding token {idx}...") + idx += 1 + self._tokens.append(MAGToken.token_decode(self.start, token)) + except AssertionError: + self.log.error(f"Token {idx} not recognized!") + + if self._tokens: + self.log.debug("Tokens decoded") + else: + self.log.critical("No tokens accepted.") + + @staticmethod + def token_decode(start, token): + log = logging.getLogger(__name__) + """ + decodes and validates the token + returns a byte array with the central token when decoded + """ + token = token.rstrip() + # length verification + log.debug("Checking token length.") + assert len(token) >= len(start) + MAGToken.MINCTLEN + MAGToken.B64CRCLEN + # header verification + log.debug("Checking token header.") + assert token[0:len(start)].lower() == start + # retrieve token in byte array form + # pad token with magical number of pad characters to make the base64 decode happy + central_token = MAGBase64.b64pad(token[len(start):-MAGToken.B64CRCLEN]) + central_token = base64.b64decode(str.encode(central_token)) + + # retrieve the precalculated checksum inside the token + end_checksum = token[-MAGToken.B64CRCLEN:] + # although the default is big endian for most libraries, we use little endian here to keep + # consistent with the encoding schemes used by other famous token systems... + calc_checksum = MAGBase64.b64enc(zlib.crc32(central_token).to_bytes(4, "little")) + # checksum verification + log.debug("Checking token checksum.") + assert calc_checksum == end_checksum + + return central_token + + @staticmethod + def wr_hmac(msg, token): + """ calculate the HMAC based on a token and the message """ + log = logging.getLogger(__name__) + log.debug(f"HMAC calculation utility called with: {msg} and {token}") + obj = hmac.new(token, msg=str.encode(msg), digestmod=hashlib.sha256) + return MAGBase64.b64enc(obj.digest()) + + def hmac_auth(self, msg, code): + """ message authentication function """ + self.log.debug(f"msg_auth called with: {msg} and {code}") + match = False + for token in self._tokens: + calc = MAGToken.wr_hmac(msg, token) + logging.debug(f"Calculated hmac as: {calc}") + if calc == code: + match = True + break + # throw an assertion if there are no matches + assert match + + def cmd_msg_auth(self, raw_msg, max_time): + """ + The JSON and HMAC code are contained in a `pair` from Kotlin and two-element `tuple` + we run this text output through this regex to decode the values within. + + The HMAC here is base64 encoded. + + Function returns None if there are no matches and a dictionary if it matches + """ + # assume unaccepted by default + retval = None + IDX_MSG = 1 + IDX_CODE = 2 + self.log.debug(f"Received in command channel: {raw_msg}") + # regex to break down the pair or tuple + matches = re.fullmatch(r"\([\"\']?(\{.+\})[\"\']?\, [\"\']?(.*?)[\"\']?\)", raw_msg) + if matches is not None: + self.log.debug(f"The split strings are: {matches[IDX_MSG]} and {matches[IDX_CODE]}") + try: + data = json.loads(matches[IDX_MSG]) + # validate message time + current_time = time.time() + sent_time = data["time"] + diff_time = current_time - sent_time + self.log.debug(f"Message time validation; Current: {current_time}, "\ + f"Sent: {sent_time}, Diff: {diff_time}") + assert abs(diff_time) <= max_time + # a possible way to prevent repeat attacks is to store old message codes since + # commands will always have a time attached to ensure uniqueness + + # validate message code + self.hmac_auth(matches[IDX_MSG], matches[IDX_CODE]) + + retval = data + except (json.JSONDecodeError, AttributeError, AssertionError) as exc: + self.log.error(str(exc)) + + return retval + + @staticmethod + def cmd_msg_gen(msg, token): + """ + The message is as a dictionary. + + The token is as a byte array + """ + msg["time"] = time.time() + msg_out = json.dumps(msg) + code = MAGToken.wr_hmac(msg_out, token) + return (msg_out, code)