Skip to content

refactor(azure-iot-device): Auth Revisions #555

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 5 commits into from
May 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions azure-iot-device/azure/iot/device/common/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .signing_mechanism import SymmetricKeySigningMechanism

# NOTE: Please import the connection_string and sastoken modules directly
# rather than through the package interface, as the modules contain many
# related items for their respective domains, which we do not wish to expose
# at length here.
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@
def _parse_connection_string(connection_string):
"""Return a dictionary of values contained in a given connection string
"""
cs_args = connection_string.split(CS_DELIMITER)
d = dict(arg.split(CS_VAL_SEPARATOR, 1) for arg in cs_args)
try:
cs_args = connection_string.split(CS_DELIMITER)
except (AttributeError, TypeError):
# NOTE: in Python 2.7, bytes will not raise an error here as they do in all other versions
raise TypeError("Connection String must be of type str")
try:
d = dict(arg.split(CS_VAL_SEPARATOR, 1) for arg in cs_args)
except ValueError:
# This occurs in an extreme edge case where a dictionary cannot be formed because there
# is only 1 token after the split (dict requires two in order to make a key/value pair)
raise ValueError("Invalid Connection String - Unable to parse")
if len(cs_args) != len(d):
# various errors related to incorrect parsing - duplicate args, bad syntax, etc.
raise ValueError("Invalid Connection String - Unable to parse")
Expand Down
96 changes: 96 additions & 0 deletions azure-iot-device/azure/iot/device/common/auth/sastoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""This module contains tools for working with Shared Access Signature (SAS) Tokens"""

import base64
import hmac
import hashlib
import time
import six.moves.urllib as urllib
from azure.iot.device.common.chainable_exception import ChainableException


class SasTokenError(ChainableException):
"""Error in SasToken"""

pass


class SasToken(object):
"""Shared Access Signature Token used to authenticate a request

Data Attributes:
expiry_time (int): Time that token will expire (in UTC, since epoch)
ttl (int): Time to live for the token, in seconds
"""

_auth_rule_token_format = (
Copy link
Collaborator

@olivakar olivakar May 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_auth_rule_token_format [](start = 4, length = 23)

why not keyname_token_format ? #ByDesign

Copy link
Member Author

@cartertinney cartertinney May 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because apparently for some reason the official name of of the skn field is not... shared key name as you might expect, and is actually.... for some reason... auth rule.

I don't get it either.


In reply to: 426946266 [](ancestors = 426946266)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this was the name in the azure portal at one point. Right now, the key names (iothubowner, etc) show up in the portal under "Shared Access Policy" as "Access policy name". We had this same thing happen with one of the DPS names -- I think ProvisioningHostName shows up in the portal with one name and in the SDK as another.


In reply to: 427500301 [](ancestors = 427500301,426946266)

"SharedAccessSignature sr={resource}&sig={signature}&se={expiry}&skn={keyname}"
)
_simple_token_format = "SharedAccessSignature sr={resource}&sig={signature}&se={expiry}"

def __init__(self, uri, signing_mechanism, key_name=None, ttl=3600):
"""
:param str uri: URI of the resouce to be accessed
:param signing_mechanism: The signing mechanism to use in the SasToken
:type signing_mechanism: Child classes of :class:`azure.iot.common.SigningMechanism`
:param str key_name: Symmetric Key Name (optional)
:param int ttl: Time to live for the token, in seconds (default 3600)

:raises: SasTokenError if an error occurs building a SasToken
"""
self._uri = uri
self._signing_mechanism = signing_mechanism
self._key_name = key_name
self._expiry_time = None # This will be overwritten by the .refresh() call below
self._token = None # This will be overwritten by the .refresh() call below

self.ttl = ttl
self.refresh()

def __str__(self):
return self._token
Copy link
Member

@BertKleewein BertKleewein May 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mental exercise :#2 (written after the one in pipeline_stages_base.py).

postulate: The notion that a SasToken has a value and that we need to periodically "refresh" that value is not quite right. It's better to think about the SasToken object as a "password generator" object. When a consumer of this class needs to talk to a network service, he can use this object to generate a password that gives him access for some period of time. When that password is set to expire, the consumer is responsible for generating a new one. This is how the code is written and I like it.

Saying token.refresh(); password=str(token); as we do, accomplishes this, but it's not quite semantically valid(). Rather, what we want is password=token.generate_new_password()

why this matters: It doesn't really matter that much, but, there an odd ownership relationship with the root owning the SasToken, object and 2 different pipelines controlling it. Both MQTT and HTTP can refresh the token. If we think of the SasToken object as having a value, having someone else change the value feels wrong. But if we think of each pipeline as owning it's own password that it gets from the generator object, it just feels better. #Pending


def refresh(self):
"""
Refresh the SasToken lifespan, giving it a new expiry time, and generating a new token.
"""
self._expiry_time = int(time.time() + self.ttl)
self._token = self._build_token()

def _build_token(self):
"""Buid SasToken representation

:returns: String representation of the token
"""
url_encoded_uri = urllib.parse.quote(self._uri, safe="")
message = url_encoded_uri + "\n" + str(self.expiry_time)
try:
signature = self._signing_mechanism.sign(message)
except Exception as e:
# Because of variant signing mechanisms, we don't know what error might be raised.
# So we catch all of them.
raise SasTokenError("Unable to build SasToken from given values", e)
url_encoded_signature = urllib.parse.quote(signature, safe="")
if self._key_name:
token = self._auth_rule_token_format.format(
resource=url_encoded_uri,
signature=url_encoded_signature,
expiry=str(self.expiry_time),
keyname=self._key_name,
)
else:
token = self._simple_token_format.format(
resource=url_encoded_uri,
signature=url_encoded_signature,
expiry=str(self.expiry_time),
)
return token

@property
def expiry_time(self):
"""Expiry Time is READ ONLY"""
return self._expiry_time
73 changes: 73 additions & 0 deletions azure-iot-device/azure/iot/device/common/auth/signing_mechanism.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""This module defines an abstract SigningMechanism, as well as common child implementations of it
"""

import six
import abc
import hmac
import hashlib
import base64
from six.moves import urllib


@six.add_metaclass(abc.ABCMeta)
class SigningMechanism(object):
@abc.abstractmethod
def sign(self, data_str):
pass


class SymmetricKeySigningMechanism(SigningMechanism):
def __init__(self, key):
"""
A mechanism that signs data using a symmetric key

:param key: Symmetric Key (base64 encoded)
:type key: str or bytes
"""
# Convert key to bytes
try:
key = key.encode("utf-8")
except AttributeError:
# If byte string, no need to encode
pass

# Derives the signing key
# CT-TODO: is "signing key" the right term?
try:
self._signing_key = base64.b64decode(key)
except (base64.binascii.Error, TypeError):
# NOTE: TypeError can only be raised in Python 2.7
raise ValueError("Invalid Symmetric Key")

def sign(self, data_str):
"""
Sign a data string with symmetric key and the HMAC-SHA256 algorithm.

:param data_str: Data string to be signed
:type data_str: str or bytes

:returns: The signed data
:rtype: str
"""
# Convert data_str to bytes
try:
data_str = data_str.encode("utf-8")
except AttributeError:
# If byte string, no need to encode
pass

# Derive signature via HMAC-SHA256 algorithm
try:
hmac_digest = hmac.HMAC(
key=self._signing_key, msg=data_str, digestmod=hashlib.sha256
).digest()
signed_data = base64.b64encode(hmac_digest)
except (TypeError):
raise ValueError("Unable to sign string using the provided symmetric key")
# Convert from bytes to string
return signed_data.decode("utf-8")
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def __init__(self, object, method_name):
self.method_name = method_name

def _get_method(self):
return getattr(self.object_weakref(), self.method_name)
return getattr(self.object_weakref(), self.method_name, None)

def __call__(self, *args, **kwargs):
return self._get_method()(*args, **kwargs)
Expand Down
5 changes: 5 additions & 0 deletions azure-iot-device/azure/iot/device/common/http_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ def request(self, method, path, callback, body="", headers={}, query_params=""):
logger.debug("connecting to host tcp socket")
connection.connect()
logger.debug("connection succeeded")
# TODO: URL formation should be moved to pipeline_stages_iothub_http, I believe, as
# depending on the operation this could have a different hostname, due to different
# destinations. For now this isn't a problem yet, because no possible client can
# support more than one HTTP operation
# (Device can do File Upload but NOT Method Invoke, Module can do Method Inovke and NOT file upload)
url = "https://{hostname}/{path}{query_params}".format(
hostname=self._hostname,
path=path,
Expand Down
4 changes: 4 additions & 0 deletions azure-iot-device/azure/iot/device/common/mqtt_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def _create_mqtt_client(self):
)

if self._proxy_options:
logger.info("Setting custom proxy options on mqtt client")
mqtt_client.proxy_set(
proxy_type=self._proxy_options.proxy_type,
proxy_addr=self._proxy_options.proxy_address,
Expand Down Expand Up @@ -325,12 +326,15 @@ def _create_ssl_context(self):
ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLSv1_2)

if self._server_verification_cert:
logger.debug("configuring SSL context with custom server verification cert")
ssl_context.load_verify_locations(cadata=self._server_verification_cert)
else:
logger.debug("configuring SSL context with default certs")
ssl_context.load_default_certs()

if self._cipher:
try:
logger.debug("configuring SSL context with cipher suites")
ssl_context.set_ciphers(self._cipher)
except ssl.SSLError as e:
# TODO: custom error with more detail?
Expand Down
31 changes: 30 additions & 1 deletion azure-iot-device/azure/iot/device/common/pipeline/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,28 @@ class BasePipelineConfig(object):
config files.
"""

def __init__(self, websockets=False, cipher="", proxy_options=None):
def __init__(
self,
hostname,
gateway_hostname=None,
sastoken=None,
x509=None,
server_verification_cert=None,
websockets=False,
cipher="",
proxy_options=None,
):
"""Initializer for BasePipelineConfig

:param str hostname: The hostname being connected to
:param str gateway_hostname: The gateway hostname optionally being used
:param sastoken: SasToken to be used for authentication. Mutually exclusive with x509.
:type sastoken: :class:`azure.iot.device.common.auth.SasToken`
:param x509: X509 to be used for authentication. Mutually exclusive with sastoken.
:type x509: :class:`azure.iot.device.models.X509`
:param str server_verification_cert: The trusted certificate chain.
Copy link
Collaborator

@olivakar olivakar May 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:param str server_verification_cert: The trusted certificate chain. [](start = 8, length = 67)

this has nothing to do with you PR...but i just wanted to mention should we update the doc string to say that it is the content of the cert chain...i have faced ppl with some confusion #Pending

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point we'll change the functionality to take both the contents or filepath


In reply to: 426949443 [](ancestors = 426949443)

Necessary when using connecting to an endpoint which has a non-standard root of trust,
such as a protocol gateway.
:param bool websockets: Enabling/disabling websockets in MQTT. This feature is relevant
if a firewall blocks port 8883 from use.
:param cipher: Optional cipher suite(s) for TLS/SSL, as a string in
Expand All @@ -30,6 +49,16 @@ def __init__(self, websockets=False, cipher="", proxy_options=None):
:param proxy_options: Details of proxy configuration
:type proxy_options: :class:`azure.iot.device.common.models.ProxyOptions`
"""
# Network
self.hostname = hostname
self.gateway_hostname = gateway_hostname

# Auth
self.sastoken = sastoken
self.x509 = x509
if (not sastoken and not x509) or (sastoken and x509):
raise ValueError("One of either 'sastoken' or 'x509' must be provided")
self.server_verification_cert = server_verification_cert
self.websockets = websockets
self.cipher = self._sanitize_cipher(cipher)
self.proxy_options = proxy_options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ def on_worker_op_complete(op, error):
return worker_op


class InitializePipelineOperation(PipelineOperation):
"""
A PipelineOperation for doing initial setup of the pipeline

Attributes can be dynamically added to this operation for use in other stages if necessary
(e.g. initialization requires a derived value)
"""

pass


class ConnectOperation(PipelineOperation):
"""
A PipelineOperation object which tells the pipeline to connect to whatever service it needs to connect to.
Expand Down Expand Up @@ -318,31 +329,6 @@ def __init__(self, feature_name, callback):
self.feature_name = feature_name


class UpdateSasTokenOperation(PipelineOperation):
"""
A PipelineOperation object which contains a SAS token used for connecting. This operation was likely initiated
by a pipeline stage that knows how to generate SAS tokens.

This operation is in the group of base operations because many different clients use the concept of a SAS token.

Even though this is an base operation, it will most likely be generated and also handled by more specifics stages
(such as IoTHub or MQTT stages).
"""

def __init__(self, sas_token, callback):
"""
Initializer for UpdateSasTokenOperation objects.

:param str sas_token: The token string which will be used to authenticate with whatever
service this pipeline connects with.
:param Function callback: The function that gets called when this operation is complete or has
failed. The callback function must accept A PipelineOperation object which indicates
the specific operation which has completed or failed.
"""
super(UpdateSasTokenOperation, self).__init__(callback=callback)
self.sas_token = sas_token


class RequestAndResponseOperation(PipelineOperation):
"""
A PipelineOperation object which wraps the common operation of sending a request to iothub with a request_id ($rid)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,6 @@
from . import PipelineOperation


class SetHTTPConnectionArgsOperation(PipelineOperation):
"""
A PipelineOperation object which contains arguments used to connect to a server using the HTTP protocol.

This operation is in the group of HTTP operations because its attributes are very specific to the HTTP protocol.
"""

def __init__(
self, hostname, callback, server_verification_cert=None, client_cert=None, sas_token=None
):
"""
Initializer for SetHTTPConnectionArgsOperation objects.
:param str hostname: The hostname of the HTTP server we will eventually connect to
:param str server_verification_cert: (Optional) The server verification certificate to use
if the HTTP server that we're going to connect to uses server-side TLS
:param X509 client_cert: (Optional) The x509 object containing a client certificate and key used to connect
to the HTTP service
:param str sas_token: The token string which will be used to authenticate with the service
:param Function callback: The function that gets called when this operation is complete or has failed.
The callback function must accept A PipelineOperation object which indicates the specific operation which
has completed or failed.
"""
super(SetHTTPConnectionArgsOperation, self).__init__(callback=callback)
self.hostname = hostname
self.server_verification_cert = server_verification_cert
self.client_cert = client_cert
self.sas_token = sas_token


class HTTPRequestAndResponseOperation(PipelineOperation):
"""
A PipelineOperation object which contains arguments used to connect to a server using the HTTP protocol.
Expand Down
Loading