diff --git a/README.rst b/README.rst index 1c7bab7..ba80538 100644 --- a/README.rst +++ b/README.rst @@ -45,16 +45,12 @@ This driver depends on: * `Adafruit CircuitPython `_ * `Adafruit CircuitPython MiniMQTT `_ - -* `CircuitPython Base64 `_ -* `CircuitPython HMAC `_ -* `CircuitPython Parse `_ +* `Adafruit CircuitPython Requests `_ +* `Adafruit CircuitPython BinASCII `_ Please ensure all dependencies are available on the CircuitPython filesystem. This is easily achieved by downloading -`the Adafruit library and driver bundle `_ -and -`the CircuitPython community library and driver bundle `_ +`the Adafruit library and driver bundle `_. Usage Example ============= diff --git a/adafruit_azureiot/__init__.py b/adafruit_azureiot/__init__.py index 5f7f04a..7ef6597 100644 --- a/adafruit_azureiot/__init__.py +++ b/adafruit_azureiot/__init__.py @@ -37,9 +37,6 @@ * Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice * Adafruit's ESP32SPI library: https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI -* Community HMAC library: https://github.com/jimbobbennett/CircuitPython_HMAC -* Community base64 library: https://github.com/jimbobbennett/CircuitPython_Base64 -* Community Parse library: https://github.com/jimbobbennett/CircuitPython_Parse """ from .iot_error import IoTError diff --git a/adafruit_azureiot/base64.py b/adafruit_azureiot/base64.py new file mode 100644 index 0000000..4cce82d --- /dev/null +++ b/adafruit_azureiot/base64.py @@ -0,0 +1,81 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`base64` +================================================================================ + +RFC 3548: Base64 Data Encodings + + +* Author(s): Jim Bennett + +Implementation Notes +-------------------- + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +import adafruit_binascii as binascii + +__all__ = ["b64encode", "b64decode"] + + +def _bytes_from_decode_data(data: str): + try: + return data.encode("ascii") + except: + raise ValueError("string argument should contain only ASCII characters") + + +def b64encode(toencode: bytes) -> bytes: + """Encode a byte string using Base64. + + toencode is the byte string to encode. Optional altchars must be a byte + string of length 2 which specifies an alternative alphabet for the + '+' and '/' characters. This allows an application to + e.g. generate url or filesystem safe Base64 strings. + + The encoded byte string is returned. + """ + # Strip off the trailing newline + return binascii.b2a_base64(toencode)[:-1] + + +def b64decode(todecode: str) -> bytes: + """Decode a Base64 encoded byte string. + + todecode is the byte string to decode. Optional altchars must be a + string of length 2 which specifies the alternative alphabet used + instead of the '+' and '/' characters. + + The decoded string is returned. A binascii.Error is raised if todecode is + incorrectly padded. + + If validate is False (the default), non-base64-alphabet characters are + discarded prior to the padding check. If validate is True, + non-base64-alphabet characters in the input result in a binascii.Error. + """ + return binascii.a2b_base64(_bytes_from_decode_data(todecode)) diff --git a/adafruit_azureiot/device_registration.py b/adafruit_azureiot/device_registration.py index dc83ab3..6b1f8ef 100644 --- a/adafruit_azureiot/device_registration.py +++ b/adafruit_azureiot/device_registration.py @@ -32,14 +32,12 @@ import gc import json import time -import circuitpython_base64 as base64 -import circuitpython_hmac as hmac -import circuitpython_parse as parse import adafruit_requests as requests import adafruit_logging as logging from adafruit_logging import Logger -import adafruit_hashlib as hashlib from . import constants +from .quote import quote +from .keys import compute_derived_symmetric_key # Azure HTTP error status codes AZURE_HTTP_ERROR_CODES = [400, 401, 404, 403, 412, 429, 500] @@ -89,17 +87,6 @@ def __init__(self, socket, id_scope: str, device_id: str, key: str, logger: Logg requests.set_socket(socket) - @staticmethod - def compute_derived_symmetric_key(secret: str, msg: str) -> bytes: - """Computes a derived symmetric key from a secret and a message - :param str secret: The secret to use for the key - :param str msg: The message to use for the key - :returns: The derived symmetric key - :rtype: bytes - """ - secret = base64.b64decode(secret) - return base64.b64encode(hmac.new(secret, msg=msg.encode("utf8"), digestmod=hashlib.sha256).digest()) - def _loop_assign(self, operation_id, headers) -> str: uri = "https://%s/%s/registrations/%s/operations/%s?api-version=%s" % ( constants.DPS_END_POINT, @@ -109,9 +96,8 @@ def _loop_assign(self, operation_id, headers) -> str: constants.DPS_API_VERSION, ) self._logger.info("- iotc :: _loop_assign :: " + uri) - target = parse.urlparse(uri) - response = self._run_get_request_with_retry(target.geturl(), headers) + response = self._run_get_request_with_retry(uri, headers) try: data = response.json() @@ -205,8 +191,8 @@ def register_device(self, expiry: int) -> str: """ # pylint: disable=C0103 sr = self._id_scope + "%2Fregistrations%2F" + self._device_id - sig_no_encode = DeviceRegistration.compute_derived_symmetric_key(self._key, sr + "\n" + str(expiry)) - sig_encoded = parse.quote(sig_no_encode, "~()*!.'") + sig_no_encode = compute_derived_symmetric_key(self._key, sr + "\n" + str(expiry)) + sig_encoded = quote(sig_no_encode, "~()*!.'") auth_string = "SharedAccessSignature sr=" + sr + "&sig=" + sig_encoded + "&se=" + str(expiry) + "&skn=registration" headers = { @@ -226,13 +212,12 @@ def register_device(self, expiry: int) -> str: self._device_id, constants.DPS_API_VERSION, ) - target = parse.urlparse(uri) self._logger.info("Connecting...") - self._logger.info("URL: " + target.geturl()) + self._logger.info("URL: " + uri) self._logger.info("body: " + json.dumps(body)) - response = self._run_put_request_with_retry(target.geturl(), body, headers) + response = self._run_put_request_with_retry(uri, body, headers) data = None try: diff --git a/adafruit_azureiot/hmac.py b/adafruit_azureiot/hmac.py new file mode 100644 index 0000000..ec9737f --- /dev/null +++ b/adafruit_azureiot/hmac.py @@ -0,0 +1,422 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`HMAC` +================================================================================ + +HMAC (Keyed-Hashing for Message Authentication) Python module. +Implements the HMAC algorithm as described by RFC 2104. + +This is here as code instead of using https://github.com/jimbobbennett/CircuitPython_HMAC.git +as we only need sha256, so just having the code we need saves 19k of RAM + +""" + +# pylint: disable=C0103, W0108, R0915, C0116, C0115 + + +def __translate(key, translation): + return bytes(translation[x] for x in key) + + +TRANS_5C = bytes((x ^ 0x5C) for x in range(256)) +TRANS_36 = bytes((x ^ 0x36) for x in range(256)) + +SHA_BLOCKSIZE = 64 +SHA_DIGESTSIZE = 32 + + +def new_shaobject(): + """Struct. for storing SHA information.""" + return { + "digest": [0] * 8, + "count_lo": 0, + "count_hi": 0, + "data": [0] * SHA_BLOCKSIZE, + "local": 0, + "digestsize": 0, + } + + +def sha_init(): + """Initialize the SHA digest.""" + sha_info = new_shaobject() + sha_info["digest"] = [ + 0x6A09E667, + 0xBB67AE85, + 0x3C6EF372, + 0xA54FF53A, + 0x510E527F, + 0x9B05688C, + 0x1F83D9AB, + 0x5BE0CD19, + ] + sha_info["count_lo"] = 0 + sha_info["count_hi"] = 0 + sha_info["local"] = 0 + sha_info["digestsize"] = 32 + return sha_info + + +ROR = lambda x, y: (((x & 0xFFFFFFFF) >> (y & 31)) | (x << (32 - (y & 31)))) & 0xFFFFFFFF +Ch = lambda x, y, z: (z ^ (x & (y ^ z))) +Maj = lambda x, y, z: (((x | y) & z) | (x & y)) +S = lambda x, n: ROR(x, n) +R = lambda x, n: (x & 0xFFFFFFFF) >> n +Sigma0 = lambda x: (S(x, 2) ^ S(x, 13) ^ S(x, 22)) +Sigma1 = lambda x: (S(x, 6) ^ S(x, 11) ^ S(x, 25)) +Gamma0 = lambda x: (S(x, 7) ^ S(x, 18) ^ R(x, 3)) +Gamma1 = lambda x: (S(x, 17) ^ S(x, 19) ^ R(x, 10)) + + +def sha_transform(sha_info): + W = [] + + d = sha_info["data"] + for i in range(0, 16): + W.append((d[4 * i] << 24) + (d[4 * i + 1] << 16) + (d[4 * i + 2] << 8) + d[4 * i + 3]) + + for i in range(16, 64): + W.append((Gamma1(W[i - 2]) + W[i - 7] + Gamma0(W[i - 15]) + W[i - 16]) & 0xFFFFFFFF) + + ss = sha_info["digest"][:] + + # pylint: disable=too-many-arguments, line-too-long + def RND(a, b, c, d, e, f, g, h, i, ki): + """Compress""" + t0 = h + Sigma1(e) + Ch(e, f, g) + ki + W[i] + t1 = Sigma0(a) + Maj(a, b, c) + d += t0 + h = t0 + t1 + return d & 0xFFFFFFFF, h & 0xFFFFFFFF + + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 0, 0x428A2F98) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 1, 0x71374491) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 2, 0xB5C0FBCF) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 3, 0xE9B5DBA5) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 4, 0x3956C25B) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 5, 0x59F111F1) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 6, 0x923F82A4) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 7, 0xAB1C5ED5) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 8, 0xD807AA98) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 9, 0x12835B01) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 10, 0x243185BE) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 11, 0x550C7DC3) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 12, 0x72BE5D74) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 13, 0x80DEB1FE) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 14, 0x9BDC06A7) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 15, 0xC19BF174) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 16, 0xE49B69C1) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 17, 0xEFBE4786) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 18, 0x0FC19DC6) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 19, 0x240CA1CC) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 20, 0x2DE92C6F) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 21, 0x4A7484AA) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 22, 0x5CB0A9DC) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 23, 0x76F988DA) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 24, 0x983E5152) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 25, 0xA831C66D) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 26, 0xB00327C8) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 27, 0xBF597FC7) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 28, 0xC6E00BF3) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 29, 0xD5A79147) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 30, 0x06CA6351) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 31, 0x14292967) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 32, 0x27B70A85) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 33, 0x2E1B2138) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 34, 0x4D2C6DFC) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 35, 0x53380D13) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 36, 0x650A7354) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 37, 0x766A0ABB) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 38, 0x81C2C92E) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 39, 0x92722C85) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 40, 0xA2BFE8A1) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 41, 0xA81A664B) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 42, 0xC24B8B70) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 43, 0xC76C51A3) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 44, 0xD192E819) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 45, 0xD6990624) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 46, 0xF40E3585) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 47, 0x106AA070) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 48, 0x19A4C116) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 49, 0x1E376C08) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 50, 0x2748774C) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 51, 0x34B0BCB5) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 52, 0x391C0CB3) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 53, 0x4ED8AA4A) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 54, 0x5B9CCA4F) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 55, 0x682E6FF3) + ss[3], ss[7] = RND(ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], 56, 0x748F82EE) + ss[2], ss[6] = RND(ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], 57, 0x78A5636F) + ss[1], ss[5] = RND(ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], ss[5], 58, 0x84C87814) + ss[0], ss[4] = RND(ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], ss[4], 59, 0x8CC70208) + ss[7], ss[3] = RND(ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], ss[3], 60, 0x90BEFFFA) + ss[6], ss[2] = RND(ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], ss[2], 61, 0xA4506CEB) + ss[5], ss[1] = RND(ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], ss[1], 62, 0xBEF9A3F7) + ss[4], ss[0] = RND(ss[1], ss[2], ss[3], ss[4], ss[5], ss[6], ss[7], ss[0], 63, 0xC67178F2) + + # Feedback + dig = [] + for i, x in enumerate(sha_info["digest"]): + dig.append((x + ss[i]) & 0xFFFFFFFF) + sha_info["digest"] = dig + + +def sha_update(sha_info, buffer): + """Update the SHA digest. + :param dict sha_info: SHA Digest. + :param str buffer: SHA buffer size. + """ + if isinstance(buffer, str): + raise TypeError("Unicode strings must be encoded before hashing") + count = len(buffer) + buffer_idx = 0 + clo = (sha_info["count_lo"] + (count << 3)) & 0xFFFFFFFF + if clo < sha_info["count_lo"]: + sha_info["count_hi"] += 1 + sha_info["count_lo"] = clo + + sha_info["count_hi"] += count >> 29 + + if sha_info["local"]: + i = SHA_BLOCKSIZE - sha_info["local"] + if i > count: + i = count + + # copy buffer + for x in enumerate(buffer[buffer_idx : buffer_idx + i]): + sha_info["data"][sha_info["local"] + x[0]] = x[1] + + count -= i + buffer_idx += i + + sha_info["local"] += i + if sha_info["local"] == SHA_BLOCKSIZE: + sha_transform(sha_info) + sha_info["local"] = 0 + else: + return + + while count >= SHA_BLOCKSIZE: + # copy buffer + sha_info["data"] = list(buffer[buffer_idx : buffer_idx + SHA_BLOCKSIZE]) + count -= SHA_BLOCKSIZE + buffer_idx += SHA_BLOCKSIZE + sha_transform(sha_info) + + # copy buffer + pos = sha_info["local"] + sha_info["data"][pos : pos + count] = list(buffer[buffer_idx : buffer_idx + count]) + sha_info["local"] = count + + +def getbuf(s): + if isinstance(s, str): + return s.encode("ascii") + return bytes(s) + + +def sha_final(sha_info): + """Finish computing the SHA Digest.""" + lo_bit_count = sha_info["count_lo"] + hi_bit_count = sha_info["count_hi"] + count = (lo_bit_count >> 3) & 0x3F + sha_info["data"][count] = 0x80 + count += 1 + if count > SHA_BLOCKSIZE - 8: + # zero the bytes in data after the count + sha_info["data"] = sha_info["data"][:count] + ([0] * (SHA_BLOCKSIZE - count)) + sha_transform(sha_info) + # zero bytes in data + sha_info["data"] = [0] * SHA_BLOCKSIZE + else: + sha_info["data"] = sha_info["data"][:count] + ([0] * (SHA_BLOCKSIZE - count)) + + sha_info["data"][56] = (hi_bit_count >> 24) & 0xFF + sha_info["data"][57] = (hi_bit_count >> 16) & 0xFF + sha_info["data"][58] = (hi_bit_count >> 8) & 0xFF + sha_info["data"][59] = (hi_bit_count >> 0) & 0xFF + sha_info["data"][60] = (lo_bit_count >> 24) & 0xFF + sha_info["data"][61] = (lo_bit_count >> 16) & 0xFF + sha_info["data"][62] = (lo_bit_count >> 8) & 0xFF + sha_info["data"][63] = (lo_bit_count >> 0) & 0xFF + + sha_transform(sha_info) + + dig = [] + for i in sha_info["digest"]: + dig.extend([((i >> 24) & 0xFF), ((i >> 16) & 0xFF), ((i >> 8) & 0xFF), (i & 0xFF)]) + return bytes(dig) + + +# pylint: disable=protected-access +class sha256: + digest_size = digestsize = SHA_DIGESTSIZE + block_size = SHA_BLOCKSIZE + name = "sha256" + + def __init__(self, s=None): + """Constructs a SHA256 hash object. + """ + self._sha = sha_init() + if s: + sha_update(self._sha, getbuf(s)) + + def update(self, s): + """Updates the hash object with a bytes-like object, s.""" + sha_update(self._sha, getbuf(s)) + + def digest(self): + """Returns the digest of the data passed to the update() + method so far.""" + return sha_final(self._sha.copy())[: self._sha["digestsize"]] + + def hexdigest(self): + """Like digest() except the digest is returned as a string object of + double length, containing only hexadecimal digits. + """ + return "".join(["%.2x" % i for i in self.digest()]) + + def copy(self): + """Return a copy (“clone”) of the hash object. + """ + new = sha256() + new._sha = self._sha.copy() + return new + + +class HMAC: + """RFC 2104 HMAC class. Also complies with RFC 4231. + + This supports the API for Cryptographic Hash Functions (PEP 247). + """ + + blocksize = 64 # 512-bit HMAC; can be changed in subclasses. + + def __init__(self, key, msg=None): + """Create a new HMAC object. + + key: key for the keyed hash object. + msg: Initial input for the hash, if provided. + digestmod: A module supporting PEP 247. *OR* + A hashlib constructor returning a new hash object. *OR* + A hash name suitable for hashlib.new(). + Defaults to hashlib.md5. + Implicit default to hashlib.md5 is deprecated and will be + removed in Python 3.6. + + Note: key and msg must be a bytes or bytearray objects. + """ + + if not isinstance(key, (bytes, bytearray)): + raise TypeError("key: expected bytes or bytearray, but got %r" % type(key).__name__) + + digestmod = sha256 + + self.digest_cons = digestmod + + self.outer = self.digest_cons() + self.inner = self.digest_cons() + self.digest_size = self.inner.digest_size + + if hasattr(self.inner, "block_size"): + blocksize = self.inner.block_size + if blocksize < 16: + blocksize = self.blocksize + else: + blocksize = self.blocksize + + # self.blocksize is the default blocksize. self.block_size is + # effective block size as well as the public API attribute. + self.block_size = blocksize + + if len(key) > blocksize: + key = self.digest_cons(key).digest() + + key = key + bytes(blocksize - len(key)) + self.outer.update(__translate(key, TRANS_5C)) + self.inner.update(__translate(key, TRANS_36)) + if msg is not None: + self.update(msg) + + @property + def name(self): + """Return the name of this object + """ + return "hmac-" + self.inner.name + + def update(self, msg): + """Update this hashing object with the string msg. + """ + self.inner.update(msg) + + def copy(self): + """Return a separate copy of this hashing object. + + An update to this copy won't affect the original object. + """ + # Call __new__ directly to avoid the expensive __init__. + other = self.__class__.__new__(self.__class__) + other.digest_cons = self.digest_cons + other.digest_size = self.digest_size + other.inner = self.inner.copy() + other.outer = self.outer.copy() + return other + + def _current(self): + """Return a hash object for the current state. + + To be used only internally with digest() and hexdigest(). + """ + hmac = self.outer.copy() + hmac.update(self.inner.digest()) + return hmac + + def digest(self): + """Return the hash value of this hashing object. + + This returns a string containing 8-bit data. The object is + not altered in any way by this function; you can continue + updating the object after calling this function. + """ + hmac = self._current() + return hmac.digest() + + def hexdigest(self): + """Like digest(), but returns a string of hexadecimal digits instead. + """ + hmac = self._current() + return hmac.hexdigest() + + +def new_hmac(key, msg=None): + """Create a new hashing object and return it. + + key: The starting key for the hash. + msg: if available, will immediately be hashed into the object's starting + state. + + You can now feed arbitrary strings into the object using its update() + method, and can ask for the hash value at any time by calling its digest() + method. + """ + return HMAC(key, msg) diff --git a/adafruit_azureiot/iot_mqtt.py b/adafruit_azureiot/iot_mqtt.py index 62537cb..f819557 100644 --- a/adafruit_azureiot/iot_mqtt.py +++ b/adafruit_azureiot/iot_mqtt.py @@ -33,10 +33,10 @@ import time import adafruit_minimqtt as minimqtt from adafruit_minimqtt import MQTT -import circuitpython_parse as parse import adafruit_logging as logging -from .device_registration import DeviceRegistration from .iot_error import IoTError +from .keys import compute_derived_symmetric_key +from .quote import quote from . import constants # pylint: disable=R0903 @@ -107,8 +107,8 @@ class IoTMQTT: def _gen_sas_token(self) -> str: token_expiry = int(time.time() + self._token_expires) uri = self._hostname + "%2Fdevices%2F" + self._device_id - signed_hmac_sha256 = DeviceRegistration.compute_derived_symmetric_key(self._key, uri + "\n" + str(token_expiry)) - signature = parse.quote(signed_hmac_sha256, "~()*!.'") + signed_hmac_sha256 = compute_derived_symmetric_key(self._key, uri + "\n" + str(token_expiry)) + signature = quote(signed_hmac_sha256, "~()*!.'") if signature.endswith("\n"): # somewhere along the crypto chain a newline is inserted signature = signature[:-1] token = "SharedAccessSignature sr={}&sig={}&se={}".format(uri, signature, token_expiry) @@ -235,6 +235,7 @@ def _handle_direct_method(self, msg: str, topic: str) -> None: method_name = topic[len_temp : topic.find("/", len_temp + 1)] ret = self._callback.direct_method_invoked(method_name, msg) + gc.collect() ret_code = 200 ret_message = "{}" @@ -261,13 +262,14 @@ def _handle_cloud_to_device_message(self, msg: str, topic: str) -> None: properties[key_value[0]] = key_value[1] self._callback.cloud_to_device_message_received(msg, properties) + gc.collect() # pylint: disable=W0702, R0912 def _on_message(self, client, msg_topic, payload) -> None: topic = "" msg = None - self._logger.info("- iot_mqtt :: _on_message :: payload(" + str(payload) + ")") + self._logger.info("- iot_mqtt :: _on_message") if payload is not None: try: @@ -447,6 +449,7 @@ def loop(self) -> None: return self._mqtts.loop() + gc.collect() def send_device_to_cloud_message(self, message, system_properties: dict = None) -> None: """Send a device to cloud message from this device to Azure IoT Hub diff --git a/adafruit_azureiot/keys.py b/adafruit_azureiot/keys.py new file mode 100644 index 0000000..08e1b7e --- /dev/null +++ b/adafruit_azureiot/keys.py @@ -0,0 +1,40 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""Computes a derived symmetric key from a secret and a message +:param str secret: The secret to use for the key +:param str msg: The message to use for the key +:returns: The derived symmetric key +:rtype: bytes +""" + +from .base64 import b64decode, b64encode +from .hmac import new_hmac + + +def compute_derived_symmetric_key(secret: str, msg: str) -> bytes: + """Computes a derived symmetric key from a secret and a message + :param str secret: The secret to use for the key + :param str msg: The message to use for the key + :returns: The derived symmetric key + :rtype: bytes + """ + return b64encode(new_hmac(b64decode(secret), msg=msg.encode("utf8")).digest()) diff --git a/adafruit_azureiot/quote.py b/adafruit_azureiot/quote.py new file mode 100644 index 0000000..5c8dd7a --- /dev/null +++ b/adafruit_azureiot/quote.py @@ -0,0 +1,138 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`quote` +================================================================================ + +The quote function %-escapes all characters that are neither in the +unreserved chars ("always safe") nor the additional chars set via the +safe arg. + +""" +_ALWAYS_SAFE = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" b"abcdefghijklmnopqrstuvwxyz" b"0123456789" b"_.-~") +_ALWAYS_SAFE_BYTES = bytes(_ALWAYS_SAFE) +SAFE_QUOTERS = {} + + +def quote(bytes_val: bytes, safe="/"): + """The quote function %-escapes all characters that are neither in the + unreserved chars ("always safe") nor the additional chars set via the + safe arg. + """ + if not isinstance(bytes_val, (bytes, bytearray)): + raise TypeError("quote_from_bytes() expected bytes") + if not bytes_val: + return "" + if isinstance(safe, str): + # Normalize 'safe' by converting to bytes and removing non-ASCII chars + safe = safe.encode("ascii", "ignore") + else: + safe = bytes([char for char in safe if char < 128]) + if not bytes_val.rstrip(_ALWAYS_SAFE_BYTES + safe): + return bytes_val.decode() + try: + quoter = SAFE_QUOTERS[safe] + except KeyError: + SAFE_QUOTERS[safe] = quoter = Quoter(safe).__getitem__ + return "".join([quoter(char) for char in bytes_val]) + + +# pylint: disable=C0103 +class defaultdict: + """ + Default Dict Implementation. + + Defaultdcit that returns the key if the key is not found in dictionnary (see + unswap in karma-lib): + >>> d = defaultdict(default=lambda key: key) + >>> d['foo'] = 'bar' + >>> d['foo'] + 'bar' + >>> d['baz'] + 'baz' + DefaultDict that returns an empty string if the key is not found (see + prefix in karma-lib for typical usage): + >>> d = defaultdict(default=lambda key: '') + >>> d['foo'] = 'bar' + >>> d['foo'] + 'bar' + >>> d['baz'] + '' + Representation of a default dict: + >>> defaultdict([('foo', 'bar')]) + defaultdict(None, {'foo': 'bar'}) + """ + + @staticmethod + # pylint: disable=W0613 + def __new__(cls, default_factory=None, **kwargs): + self = super(defaultdict, cls).__new__(cls) + # pylint: disable=C0103 + self.d = {} + return self + + def __init__(self, default_factory=None, **kwargs): + self.d = kwargs + self.default_factory = default_factory + + def __getitem__(self, key): + try: + return self.d[key] + except KeyError: + val = self.__missing__(key) + self.d[key] = val + return val + + def __setitem__(self, key, val): + self.d[key] = val + + def __delitem__(self, key): + del self.d[key] + + def __contains__(self, key): + return key in self.d + + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + return self.default_factory() + + +class Quoter(defaultdict): + """A mapping from bytes (in range(0,256)) to strings. + + String values are percent-encoded byte values, unless the key < 128, and + in the "safe" set (either the specified safe set, or default set). + """ + + # Keeps a cache internally, using defaultdict, for efficiency (lookups + # of cached keys don't call Python code at all). + def __init__(self, safe): + """safe: bytes object.""" + super(Quoter, self).__init__() + self.safe = _ALWAYS_SAFE.union(safe) + + def __missing__(self, b): + # Handle a cache miss. Store quoted string in cache and return. + res = chr(b) if b in self.safe else "%{:02X}".format(b) + self[b] = res + return res diff --git a/docs/conf.py b/docs/conf.py index 41677b7..e468391 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,7 @@ # Uncomment the below if you use native CircuitPython modules such as # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. -autodoc_mock_imports = ["adafruit_logging", "adafruit_requests", "adafruit_hashlib", "adafruit_ntp"] +autodoc_mock_imports = ["adafruit_binascii", "adafruit_logging", "adafruit_requests", "adafruit_hashlib", "adafruit_ntp"] intersphinx_mapping = { diff --git a/examples/iotcentral_commands.py b/examples/iotcentral_commands.py index e8c3a56..a12a801 100644 --- a/examples/iotcentral_commands.py +++ b/examples/iotcentral_commands.py @@ -85,11 +85,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTCentralDevice from adafruit_azureiot.iot_mqtt import IoTResponse diff --git a/examples/iotcentral_notconnected.py b/examples/iotcentral_notconnected.py index 4a9f4d6..d81107a 100644 --- a/examples/iotcentral_notconnected.py +++ b/examples/iotcentral_notconnected.py @@ -87,11 +87,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTCentralDevice, IoTError # Create an IoT Hub device client and connect diff --git a/examples/iotcentral_properties.py b/examples/iotcentral_properties.py index 15dde99..71caf6b 100644 --- a/examples/iotcentral_properties.py +++ b/examples/iotcentral_properties.py @@ -86,11 +86,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTCentralDevice # Create an IoT Hub device client and connect diff --git a/examples/iotcentral_simpletest.py b/examples/iotcentral_simpletest.py index 571ee38..e0cbf96 100644 --- a/examples/iotcentral_simpletest.py +++ b/examples/iotcentral_simpletest.py @@ -87,11 +87,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTCentralDevice # Create an IoT Hub device client and connect diff --git a/examples/iothub_directmethods.py b/examples/iothub_directmethods.py index 341a51f..2a00462 100644 --- a/examples/iothub_directmethods.py +++ b/examples/iothub_directmethods.py @@ -80,11 +80,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTHubDevice from adafruit_azureiot.iot_mqtt import IoTResponse diff --git a/examples/iothub_messages.py b/examples/iothub_messages.py index 52524b1..decd7bc 100644 --- a/examples/iothub_messages.py +++ b/examples/iothub_messages.py @@ -82,11 +82,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTHubDevice # Create an IoT Hub device client and connect diff --git a/examples/iothub_simpletest.py b/examples/iothub_simpletest.py index 703190c..5ae4f0f 100644 --- a/examples/iothub_simpletest.py +++ b/examples/iothub_simpletest.py @@ -82,11 +82,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTHubDevice # Create an IoT Hub device client and connect diff --git a/examples/iothub_twin_operations.py b/examples/iothub_twin_operations.py index 83ef88b..12a78f8 100644 --- a/examples/iothub_twin_operations.py +++ b/examples/iothub_twin_operations.py @@ -84,11 +84,7 @@ # # From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): # * adafruit-circuitpython-minimqtt -# -# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): -# * circuitpython-hmac -# * circuitpython-base64 -# * circuitpython-parse +# * adafruit-circuitpython-requests from adafruit_azureiot import IoTHubDevice # Create an IoT Hub device client and connect diff --git a/requirements.txt b/requirements.txt index 711349f..f488495 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Adafruit-Blinka Adafruit-CircuitPython-miniMQTT -CircuitPython-HMAC -CircuitPython-Base64 -CircuitPython-Parse \ No newline at end of file +Adafruit-CircuitPython-Requests +Adafruit-CircuitPython-Binascii \ No newline at end of file diff --git a/setup.py b/setup.py index 8cc5055..0af8ac5 100644 --- a/setup.py +++ b/setup.py @@ -31,11 +31,9 @@ author_email="circuitpython@adafruit.com", install_requires=[ "Adafruit-Blinka", - "Adafruit_CircuitPython_ESP32SPI", "Adafruit-CircuitPython-miniMQTT", - "CircuitPython-HMAC", - "CircuitPython-Base64", - "CircuitPython-Parse", + "Adafruit-CircuitPython-Requests", + "Adafruit-CircuitPython-Binascii", ], # Choose your license license="MIT",