Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4f08d19
updating azure-keyvault version and changelog
Sep 22, 2017
bef5e13
Merge branch 'master' of https://github.com/Azure/azure-sdk-for-python
Oct 10, 2017
d91b015
adding http_challange to support pop and bearer challanges
Oct 12, 2017
35fdd5c
adding classes for jose object handling
Nov 16, 2017
1cce33c
message encryption updates from testing
Dec 1, 2017
86ad403
removing print statements used for debugging
Dec 8, 2017
75566ef
fixing keyvault test base and adding docstrings
Dec 12, 2017
455c66b
Updating http challenge headers for message protection
Dec 13, 2017
091c6af
Merge pull request #1 from JeffSimmer/mcrypt
Dec 13, 2017
2376828
Merge branch 'mcrypt' of https://github.com/schaabs/azure-sdk-for-pyt…
Dec 13, 2017
d649ff7
Add backwards compatible ctor for HttpChallange
Dec 14, 2017
96f4cac
removing custom\jose.py as its contents has been moved to custom\inte…
Dec 14, 2017
428f7f0
updates for PR feedback
Dec 14, 2017
a061bef
moving RsaKey class to internal.py and marking internal classes with _
Dec 14, 2017
7f3295b
fixing regression introduced with feedback updates
Dec 14, 2017
d3df142
updating cek to be regenerated on each encrypted message
Jan 10, 2018
bd0be97
adding tests for internals used in message encryption
Jan 24, 2018
0d5f0d5
updating aes_hmac encryption / decryption to use cryptography primitives
Jan 24, 2018
c750e77
Merge branch 'keyvault_1.0_preview' of https://github.com/Azure/azure…
Jan 24, 2018
849f57d
fixing cyrptography version requirement in setup.py
Jan 24, 2018
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
9 changes: 7 additions & 2 deletions azure-keyvault/azure/keyvault/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from .custom import http_bearer_challenge_cache as HttpBearerChallengeCache
from .custom.http_bearer_challenge import HttpBearerChallenge
from .custom.http_challenge import HttpChallenge
from .custom.key_vault_client import CustomKeyVaultClient as KeyVaultClient
from .custom.key_vault_id import (KeyVaultId,
KeyId,
Expand All @@ -20,7 +21,8 @@
CertificateOperationId,
StorageAccountId,
StorageSasDefinitionId)
from .custom.key_vault_authentication import KeyVaultAuthentication, KeyVaultAuthBase
from .custom.key_vault_authentication import KeyVaultAuthentication, KeyVaultAuthBase, AccessToken
from .custom.http_message_security import generate_pop_key
from .version import VERSION

__all__ = ['KeyVaultClient',
Expand All @@ -34,8 +36,11 @@
'StorageSasDefinitionId',
'HttpBearerChallengeCache',
'HttpBearerChallenge',
'HttpChallenge',
'KeyVaultAuthentication',
'KeyVaultAuthBase']
'KeyVaultAuthBase',
'generate_pop_key',
'AccessToken']

__version__ = VERSION

115 changes: 115 additions & 0 deletions azure-keyvault/azure/keyvault/custom/http_challenge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#---------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
#---------------------------------------------------------------------------------------------

try:
import urllib.parse as parse
except ImportError:
import urlparse as parse # pylint: disable=import-error


class HttpChallenge(object):

def __init__(self, request_uri, challenge, response_headers=None):
""" Parses an HTTP WWW-Authentication Bearer challenge from a server. """
self.source_authority = self._validate_request_uri(request_uri)
self.source_uri = request_uri
self._parameters = {}

# get the scheme of the challenge and remove from the challenge string
trimmed_challenge = self._validate_challenge(challenge)
split_challenge = trimmed_challenge.split(' ', 1)
self.scheme = split_challenge[0]
trimmed_challenge = split_challenge[1]

# split trimmed challenge into comma-separated name=value pairs. Values are expected
# to be surrounded by quotes which are stripped here.
for item in trimmed_challenge.split(','):
# process name=value pairs
comps = item.split('=')
if len(comps) == 2:
key = comps[0].strip(' "')
value = comps[1].strip(' "')
if key:
self._parameters[key] = value

# minimum set of parameters
if not self._parameters:
raise ValueError('Invalid challenge parameters')

# must specify authorization or authorization_uri
if 'authorization' not in self._parameters and 'authorization_uri' not in self._parameters:
raise ValueError('Invalid challenge parameters')

# if the response headers were supplied
if response_headers:
# get the message signing key and message key encryption key from the headers
self.server_signature_key = response_headers.get('x-ms-message-signing-key', None)
self.server_encryption_key = response_headers.get('x-ms-message-encryption-key', None)

def is_bearer_challenge(self):
""" Tests whether the HttpChallenge a Bearer challenge.
rtype: bool """
if not self.scheme:
return False

return self.scheme.lower() == 'bearer'

def is_pop_challenge(self):
""" Tests whether the HttpChallenge is a proof of possession challenge.
rtype: bool """
if not self.scheme:
return False

return self.scheme.lower() == 'pop'

def get_value(self, key):
return self._parameters.get(key)

def get_authorization_server(self):
""" Returns the URI for the authorization server if present, otherwise empty string. """
value = ''
for key in ['authorization_uri', 'authorization']:
value = self.get_value(key) or ''
if value:
break
return value

def get_resource(self):
""" Returns the resource if present, otherwise empty string. """
return self.get_value('resource') or ''

def get_scope(self):
""" Returns the scope if present, otherwise empty string. """
return self.get_value('scope') or ''

def supports_pop(self):
""" Returns True if challenge supports pop token auth else False """
return self._parameters.get('supportspop', '').lower() == 'true'

def supports_message_protection(self):
""" Returns True if challenge vault supports message protection """
return self.supports_pop() and self.server_encryption_key and self.server_signature_key

def _validate_challenge(self, challenge):
""" Verifies that the challenge is a valid auth challenge and returns the key=value pairs. """
if not challenge:
raise ValueError('Challenge cannot be empty')

return challenge.strip()

# pylint: disable=no-self-use
def _validate_request_uri(self, uri):
""" Extracts the host authority from the given URI. """
if not uri:
raise ValueError('request_uri cannot be empty')

uri = parse.urlparse(uri)
if not uri.netloc:
raise ValueError('request_uri must be an absolute URI')

if uri.scheme.lower() not in ['http', 'https']:
raise ValueError('request_uri must be HTTP or HTTPS')

return uri.netloc
192 changes: 192 additions & 0 deletions azure-keyvault/azure/keyvault/custom/http_message_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#---------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
#---------------------------------------------------------------------------------------------

import json
import time
import os
from .internal import _a128cbc_hs256_encrypt, _a128cbc_hs256_decrypt, _JwsHeader, _JwsObject, \
_JweHeader, _JweObject, _str_to_b64url, _bstr_to_b64url, _b64_to_bstr, _RsaKey


def generate_pop_key():
"""
Generates a key which can be used for Proof Of Possession token authentication.
:return:
"""
return _RsaKey.generate()


class HttpMessageSecurity(object):
"""
Used for message authorization, encryption and decrtyption.

This class is intended for internal use only. Details are subject to non-compatible changes, consumers of the
azure-keyvault module should not take dependencies on this class or its current implementation.
"""
def __init__(self, client_security_token=None,
client_signature_key=None,
client_encryption_key=None,
server_signature_key=None,
server_encryption_key=None):
self.client_security_token = client_security_token
self.client_signature_key = client_signature_key
self.client_encryption_key = client_encryption_key
self.server_signature_key = server_signature_key
self.server_encryption_key = server_encryption_key

def protect_request(self, request):
"""
Adds authorization header, and encrypts and signs the request if supported on the specific request.
:param request: unprotected request to apply security protocol
:return: protected request with appropriate security protocal applied
"""
# Setup the auth header on the request
# Due to limitations in the service we hard code the auth scheme to 'Bearer' as the service will fail with any
# other scheme or a different casing such as 'bearer', once this is fixed the following line should be replaced:
# request.headers['Authorization'] = '{} {}'.format(auth[0], auth[1])
request.headers['Authorization'] = '{} {}'.format('Bearer', self.client_security_token)

# if the current message security doesn't support message protection, or the body is empty
# skip protection and return the original request
if not self.supports_protection() or len(request.body) == 0:
return request

plain_text = request.body

# if the client encryption key is specified add it to the body of the request
if self.client_encryption_key:
# note that this assumes that the body is already json and not simple string content
# this is true for all requests which currently support message encryption, but might
# need to be revisited when the types of
body_dict = json.loads(plain_text)
body_dict['rek'] = {'jwk': self.client_encryption_key.to_jwk().serialize()}
plain_text = json.dumps(body_dict).encode(encoding='utf8')

# build the header for the jws body
jws_header = _JwsHeader()
jws_header.alg = 'RS256'
jws_header.kid = self.client_signature_key.kid
jws_header.at = self.client_security_token
jws_header.ts = int(time.time())
jws_header.typ = 'PoP'

jws = _JwsObject()

jws.protected = jws_header.to_compact_header()
jws.payload = self._protect_payload(plain_text)
data = (jws.protected + '.' + jws.payload).encode('ascii')
jws.signature = _bstr_to_b64url(self.client_signature_key.sign(data))

request.headers['Content-Type'] = 'application/jose+json'

request.prepare_body(data=jws.to_flattened_jws(), files=None)

return request

def unprotect_response(self, response, **kwargs):
"""
Removes protection from the specified response
:param request: response from the key vault service
:return: unprotected response with any security protocal encryption removed
"""
body = response.content
# if the current message security doesn't support message protection, the body is empty, or the request failed
# skip protection and return the original response
if not self.supports_protection() or len(response.content) == 0 or response.status_code != 200:
return response

# ensure the content-type is application/jose+json
if 'application/jose+json' not in response.headers.get('content-type', '').lower():
raise ValueError('Invalid protected response')

# deserialize the response into a JwsObject, using response.text so requests handles the encoding
jws = _JwsObject().deserialize(body)

# deserialize the protected header
jws_header = _JwsHeader.from_compact_header(jws.protected)

# ensure the jws signature kid matches the key from original challenge
# and the alg matches expected signature alg
if jws_header.kid != self.server_signature_key.kid \
or jws_header.alg != 'RS256':
raise ValueError('Invalid protected response')

# validate the signature of the jws
data = (jws.protected + '.' + jws.payload).encode('ascii')
# verify will raise an InvalidSignature exception if the signature doesn't match
self.server_signature_key.verify(signature=_b64_to_bstr(jws.signature), data=data)

# get the unprotected response body
decrypted = self._unprotect_payload(jws.payload)

response._content = decrypted
response.headers['Content-Type'] = 'application/json'

return response

def supports_protection(self):
"""
Determines if the the current HttpMessageSecurity object supports the message protection protocol.
:return: True if the current object supports protection, otherwise False
"""
return self.client_signature_key \
and self.client_encryption_key \
and self.server_signature_key \
and self.server_encryption_key

def _protect_payload(self, plaintext):
# create the jwe header for the payload
kek = self.server_encryption_key
jwe_header = _JweHeader()
jwe_header.alg = 'RSA-OAEP'
jwe_header.kid = kek.kid
jwe_header.enc = 'A128CBC-HS256'

# create the jwe object
jwe = _JweObject()
jwe.protected = jwe_header.to_compact_header()

# generate the content encryption key and iv
cek = os.urandom(32)
iv = os.urandom(16)
jwe.iv = _bstr_to_b64url(iv)
# wrap the cek using the server encryption key
wrapped = _bstr_to_b64url(kek.encrypt(cek))
jwe.encrypted_key = wrapped

# encrypt the plaintext body with the cek using the protected header
# as the authdata to get the ciphertext and the authtag
ciphertext, tag = _a128cbc_hs256_encrypt(cek, iv, plaintext, jwe.protected.encode('ascii'))

jwe.ciphertext = _bstr_to_b64url(ciphertext)
jwe.tag = _bstr_to_b64url(tag)

# flatten and encode the jwe for the final jws payload content
flat = jwe.to_flattened_jwe()
return _str_to_b64url(flat)

def _unprotect_payload(self, payload):
# deserialize the payload
jwe = _JweObject().deserialize_b64(payload)

# deserialize the payload header
jwe_header = _JweHeader.from_compact_header(jwe.protected)

# ensure the kid matches the specified client encryption key
# and the key wrap alg and the data encryption enc match the expected
if self.client_encryption_key.kid != jwe_header.kid \
or jwe_header.alg != 'RSA-OAEP' \
or jwe_header.enc != 'A128CBC-HS256':
raise ValueError('Invalid protected response')

# unwrap the cek using the client encryption key
cek = self.client_encryption_key.decrypt(_b64_to_bstr(jwe.encrypted_key))

# decrypt the cipher text to get the unprotected body content
return _a128cbc_hs256_decrypt(cek,
_b64_to_bstr(jwe.iv),
_b64_to_bstr(jwe.ciphertext),
jwe.protected.encode('ascii'),
_b64_to_bstr(jwe.tag))
Loading