diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6afa43e --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: dist + +dist: + python setup.py sdist bdist_wheel + +publish: dist + pip install 'twine>=1.5.0' + twine upload dist/* + rm -fr build dist .egg *.egg-info diff --git a/README.md b/README.md index 7bed2a6..ab0d32c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,36 @@ -# python-keyprotect +# keyprotect-python-client [![Build Status](https://travis-ci.org/locke105/python-keyprotect.svg?branch=master)](https://travis-ci.org/locke105/python-keyprotect) [![Apache License](http://img.shields.io/badge/license-APACHE2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) A Pythonic client for IBM Key Protect +This is a thin wrapper around the KeyProtect client in the [redstone](https://github.com/IBM/redstone) Python package. For detailed documentation and API references, please see the [redstone docs](https://redstone-py.readthedocs.org) + # Usage +The following python is a quick example of how to use the keyprotect module. + +The example expects `IBMCLOUD_API_KEY` to be set to a valid IAM API key, +and `KP_INSTANCE_ID` to be set to the UUID identifying your KeyProtect instance. + ```python +import os + import keyprotect from keyprotect import bxauth -service_id="..." -api_key="..." -tm = bxauth.TokenManager(api_key=api_key) -iam_token = tm.get_token() +tm = bxauth.TokenManager(api_key=os.getenv("IBMCLOUD_API_KEY")) -kp = keyprotect.Keys( - iamtoken=iam_token, +kp = keyprotect.Client( + credentials=tm, region="us-south", - instance_id=service_id + service_instance_id=os.getenv("KP_INSTANCE_ID") ) -for key in kp.index(): - print("%s\t%s" % (key['id'], key['name'])) +for key in kp.keys(): + print("%s\t%s" % (key["id"], key["name"])) key = kp.create(name="MyTestKey") print("Created key '%s'" % key['id']) diff --git a/keyprotect/__init__.py b/keyprotect/__init__.py index 05ca93c..ba0c164 100644 --- a/keyprotect/__init__.py +++ b/keyprotect/__init__.py @@ -1,6 +1,9 @@ from __future__ import absolute_import -# alias keyprotect -> keyprotect.keyprotect -# keeps the keyprotect.py module as copy-able single file, -# but we can package it in its own namespace as an installable as well -from keyprotect.keyprotect import * # noqa: F401,F403 +import redstone +from redstone import auth as bxauth # noqa: F401 + + +def Client(*args, **kwargs): # noqa: N802 + cl = redstone.service("KeyProtect", *args, **kwargs) + return cl diff --git a/keyprotect/bxauth.py b/keyprotect/bxauth.py deleted file mode 100644 index 5f2f7b9..0000000 --- a/keyprotect/bxauth.py +++ /dev/null @@ -1,281 +0,0 @@ -# Copyright 2018 Mathew Odden -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import print_function - -import base64 -import json -import logging -import os -import pprint -import sys -import time - -try: - from urllib.parse import urlparse, urlencode, urlunsplit -except ImportError: - from urlparse import urlparse, urlunsplit - from urllib import urlencode - -try: - import http.client as httplib -except ImportError: - import httplib - - -LOG = logging.getLogger(__name__) - - -class TokenManager(object): - - def __init__(self, api_key, iam_endpoint=None): - self.api_key = api_key - self.iam_endpoint = iam_endpoint - self._token_info = {} - - def get_token(self): - if (not self._token_info.get('access_token') or - self.is_refresh_token_expired()): - self._request_token() - elif self.is_token_expired(): - self._refresh_token() - - return self._token_info.get('access_token') - - def _request_token(self): - token_resp = auth(apikey=self.api_key, iam_endpoint=self.iam_endpoint) - if isinstance(token_resp, dict): - self._token_info = token_resp - else: - raise Exception("Error getting token: %s" % token_resp) - - def _refresh_token(self): - token_resp = auth( - refresh_token=self._token_info.get('refresh_token'), - iam_endpoint=self.iam_endpoint) - if isinstance(token_resp, dict): - self._token_info = token_resp - else: - raise Exception("Error getting refreshing token: %s" % token_resp) - - def is_token_expired(self): - # refresh even with 20% time still remainig, - # this should be 12 minutes before expiration for 1h tokens - token_expire_time = self._token_info.get('expiration', 0) - token_expires_in = self._token_info.get('expires_in', 0) - return (time.time() >= (token_expire_time - (0.2 * token_expires_in))) - - def is_refresh_token_expired(self): - # no idea how long these last, - # but some other code suggested up to 30 days, - # but it was also assuming they expire within 7 days... - # assume 7 days, because better safe than sorry - day = 24 * 60 * 60 - refresh_expire_time = self._token_info.get('expiration', 0) + (7 * day) - return (time.time() >= refresh_expire_time) - - -def request(method, url, body=None, data=None, headers=None): - parts = urlparse(url) - - if parts.scheme == 'https': - conn = httplib.HTTPSConnection(parts.netloc) - else: - conn = httplib.HTTPConnection(parts.netloc) - - headers = headers if headers else {} - - if data: - headers['Content-Type'] = 'application/x-www-form-urlencoded' - body = urlencode(data) - - path = urlunsplit( - ('', '', parts.path, parts.query, parts.fragment)) - - LOG.debug(get_curl(method, url, headers)) - - LOG.info('httplib %s %s' % (method, path)) - LOG.debug('headers=%s' % pprint.pformat(headers)) - LOG.debug('body=%r' % body) - conn.request(method, path, body=body, headers=headers) - resp = conn.getresponse() - - LOG.info('httplib response - %s %s' % (resp.status, resp.reason)) - - return resp - - -def get_curl(method, url, headers): - header_strs = [] - for k, v in headers.items(): - header_strs.append('-H "%s: %s"' % (k, v)) - - header_str = ' '.join(header_strs) - - curl_str = 'curl -v -X%(method)s %(headers)s "%(url)s"' % { - 'method': method, - 'headers': header_str, - 'url': url - } - - return curl_str - - -def auth(username=None, password=None, apikey=None, - refresh_token=None, iam_endpoint=None): - """ - Makes a authentication request to the IAM api - :param username: Username - :param password: Password - :param apikey: IBMCloud/Bluemix API Key - :param refresh_token: IBM IAM Refresh Token, - if specified the refresh token is used to authenticate, - instead of the API key - :param iam_endpoint: base URL that can be specified - to override the default IAM endpoint, if one, for example, - wanted to test against their own IAM or an internal server - :return: Response - """ - if not iam_endpoint: - iam_endpoint = 'https://iam.cloud.ibm.com/' - - if iam_endpoint[-1] == '/': - iam_endpoint = iam_endpoint[:-1] - - api_endpoint = iam_endpoint + '/oidc/token' - - # HTTP Headers - headers = { - 'Authorization': 'Basic Yng6Yng=', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - } - - # HTTP Payload - data = { - 'response_type': 'cloud_iam', - 'uaa_client_id': 'cf', - 'uaa_client_secret': '' - } - - # Setup grant type - if apikey: - data['grant_type'] = 'urn:ibm:params:oauth:grant-type:apikey' - data['apikey'] = apikey - elif refresh_token: - data['grant_type'] = 'refresh_token' - data['refresh_token'] = refresh_token - elif username and password: - data['grant_type'] = 'password' - data['username'] = username - data['password'] = password - else: - raise ValueError( - "Must specify one of username/password, apikey, or refresh_token!") - - encoded = urlencode(data) - - resp = request('POST', api_endpoint, body=encoded, headers=headers) - - if resp.status == 200: - jsonable = json.loads(resp.read()) - return jsonable - - return resp.read() - - -def get_orgs(bearer_token): - api_endpoint = 'https://api.ng.bluemix.net/v2/organizations' - - headers = { - 'Content-Type': 'application/x-www-form-urlencoded;charset=utf', - 'Authorization': 'Bearer %s' % bearer_token, - 'Accept': 'application/json;charset=utf-8' - } - - resp = request('GET', api_endpoint, headers=headers) - return resp.read() - - -def get_spaces(bearer_token, spaces_path): - api_endpoint = 'https://api.ng.bluemix.net%s' % spaces_path - - headers = { - 'Content-Type': 'application/x-www-form-urlencoded;charset=utf', - 'Authorization': 'Bearer %s' % bearer_token, - 'Accept': 'application/json;charset=utf-8' - } - - resp = request('GET', api_endpoint, headers=headers) - return resp.read() - - -def find_space_and_org(bearer_token, org_name, space_name): - org_resp = get_orgs(bearer_token) - org_data = json.loads(org_resp) - - for org in org_data['resources']: - if org_name == org.get('entity', {}).get('name'): - org_info = org - break - - space_resp = get_spaces(bearer_token, org_info['entity']['spaces_url']) - space_data = json.loads(space_resp) - - for space in space_data['resources']: - if space_name == space.get('entity', {}).get('name'): - space_info = space - break - - return org_info, space_info - - -def inspect_token(token): - parts = token.split(".")[:2] - decoded_parts = [] - for part in parts: - padding = '=' * (len(part) % 4) - part = str(part) - decoded_part = base64.urlsafe_b64decode(part + padding) - try: - decoded_part = json.loads(decoded_part) - except ValueError: - pass - decoded_parts.append(decoded_part) - - return decoded_parts - - -def main(): - api_key = None - - # iterate through possible things that could be used to get us the key, - # last one that is not None or empty will win - possible_keys = [os.environ.get('BLUEMIX_API_KEY'), - os.environ.get('IBMCLOUD_API_KEY')] - for pkey in possible_keys: - if pkey: - api_key = pkey - - if not api_key: - print("error: please set BLUEMIX_API_KEY or IBMCLOUD_API_KEY", - file=sys.stderr) - return 1 - - tokman = TokenManager(api_key=api_key) - print(tokman.get_token()) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/keyprotect/keyprotect.py b/keyprotect/keyprotect.py deleted file mode 100644 index 59a7b83..0000000 --- a/keyprotect/keyprotect.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright 2018 Mathew Odden -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import print_function - -import base64 -import io -import logging - -import requests - - -LOG = logging.getLogger(__name__) - -DEBUG_CURL = False - - -def get_curl_cmd(req): - curl_cmd = "curl -X%(method)s '%(url)s'" % req.__dict__ - for header, val in req.headers.items(): - curl_cmd += " -H '%s: %s'" % (header, val) - if req.body: - curl_cmd += " -d '%s'" % req.body - return curl_cmd - - -def get_endpoint_for_region(region): - return "https://keyprotect.%s.bluemix.net" % region - - -class KeyState(object): - # see NIST SP 800-57 - # the KeyProtect API docs only define the following for some reason - PREACTIVATION = 0 - ACTIVE = 1 - DEACTIVATED = 3 - DESTROYED = 5 - - -class Keys(object): - - def __init__(self, iamtoken, region, instance_id, - verify=True, endpoint_url=None): - self._headers = {} - self._headers['Authorization'] = "Bearer %s" % iamtoken - self._headers['Bluemix-Instance'] = instance_id - self.session = requests.Session() - self.session.verify = verify - - if endpoint_url: - self.endpoint_url = endpoint_url - else: - self.endpoint_url = get_endpoint_for_region(region) - - def _validate_resp(self, resp): - - def log_resp(resp): - resp_str = io.StringIO() - print(u"%d %s" % (resp.status_code, resp.reason), file=resp_str) - - for k, v in resp.headers.items(): - if k.lower() == 'authorization': - v = 'REDACTED' - print(u"%s: %s" % (k, v), file=resp_str) - - print(resp.content.decode(), end=u'', file=resp_str) - return resp_str.getvalue() - - try: - if DEBUG_CURL: - print(get_curl_cmd(resp.request)) - - LOG.debug(log_resp(resp)) - - resp.raise_for_status() - except requests.HTTPError as http_err: - http_err.raw_response = log_resp(resp) - raise http_err - - def index(self): - resp = self.session.get( - "%s/api/v2/keys" % self.endpoint_url, - headers=self._headers) - - self._validate_resp(resp) - - return resp.json().get('resources', []) - - def get(self, key_id): - resp = self.session.get( - "%s/api/v2/keys/%s" % (self.endpoint_url, key_id), - headers=self._headers) - - self._validate_resp(resp) - - return resp.json().get('resources')[0] - - def create(self, name, payload=None, raw_payload=None, root=False): - - data = { - "metadata": { - "collectionType": "application/vnd.ibm.kms.key+json", - "collectionTotal": 1}, - "resources": [ - { - "type": "application/vnd.ibm.kms.key+json", - "extractable": not root, - "name": name - } - ] - } - - # use raw_payload if given, else assume payload needs some base64 love - if raw_payload is not None: - data['resources'][0]['payload'] = raw_payload - elif payload is not None: - data['resources'][0]['payload'] = base64.b64encode(payload) - - resp = self.session.post( - "%s/api/v2/keys" % self.endpoint_url, - headers=self._headers, - json=data) - self._validate_resp(resp) - return resp.json().get('resources')[0] - - def delete(self, key_id): - resp = self.session.delete( - "%s/api/v2/keys/%s" % (self.endpoint_url, key_id), - headers=self._headers) - self._validate_resp(resp) - - def _action(self, key_id, action, jsonable): - resp = self.session.post( - "%s/api/v2/keys/%s" % (self.endpoint_url, key_id), - headers=self._headers, - params={"action": action}, - json=jsonable) - self._validate_resp(resp) - return resp.json() - - def wrap(self, key_id, plaintext, aad=None): - data = {'plaintext': base64.b64encode(plaintext).decode()} - - if aad: - data['aad'] = aad - - return self._action(key_id, "wrap", data) - - def unwrap(self, key_id, ciphertext, aad=None): - data = {'ciphertext': ciphertext} - - if aad: - data['aad'] = aad - - resp = self._action(key_id, "unwrap", data) - return base64.b64decode(resp['plaintext'].encode()) diff --git a/setup.py b/setup.py index 9883614..2ff0f38 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,37 @@ import setuptools + +with open("README.md", "r") as rfh: + long_description = rfh.read() + + setuptools.setup( - name = "python-keyprotect", - version = "0.2.0", - url = "https://github.com/locke105/python-keyprotect", + name = "keyprotect", + version = "2.0.0", + description = "A Pythonic client for IBM Key Protect", + long_description = long_description, + long_description_content_type = "text/markdown", + url = "https://github.com/IBM/keyprotect-python-client", + license = "Apache 2.0", author = "Mathew Odden", author_email = "mathewrodden@gmail.com", packages = setuptools.find_packages(), install_requires = [ - 'requests[security]' - ] + "redstone==0.3.0", + ], + python_requires=">=3.5", + classifiers = [ + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], ) diff --git a/test/integration.py b/test/integration.py index e87bf7c..e2f9ebe 100644 --- a/test/integration.py +++ b/test/integration.py @@ -11,35 +11,32 @@ from keyprotect import bxauth -#service_id="ce46b1ab-c71f-4b1f-9fc4-4a774a49260c" -service_id = os.environ.get('KP_INSTANCE_ID') +service_id = os.environ.get("KP_INSTANCE_ID") +region_name = os.environ.get("KP_INSTANCE_REGION") or "us-east" env_vars = [os.environ.get('IBMCLOUD_API_KEY'), os.environ.get('BLUEMIX_API_KEY')] -# iterate throuh in order and use first one that is not nil/empty +# iterate through possible API key vars +# in order and use first one that is not nil/empty for var in env_vars: if var: apikey = var break -def get_client(region): +def main(): tm = bxauth.TokenManager(api_key=apikey) - return keyprotect.Keys( - iamtoken=tm.get_token(), - region=region, - instance_id=service_id + kp = keyprotect.Client( + credentials=tm, + region=region_name, + service_instance_id=service_id, ) - -def main(): - kp = get_client(region="us-east") - - for key in kp.index(): + for key in kp.keys(): print("%s\t%s" % (key['id'], key['name'])) key = kp.create(name="MyTestKey") diff --git a/tox.ini b/tox.ini index f9798e1..f88eb23 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = flake8 [testenv] -passenv = BLUEMIX_API_KEY IBMCLOUD_API_KEY KP_INSTANCE_ID +passenv = BLUEMIX_API_KEY IBMCLOUD_API_KEY KP_INSTANCE_ID KP_INSTANCE_REGION deps = commands = python test/integration.py