From 8a7741ae89d61f64e1c2fe7f8ecf0c0c4083eb3f Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Tue, 19 Apr 2016 16:34:04 -0700 Subject: [PATCH 01/15] Data model changes --- st2common/in-requirements.txt | 1 + st2common/st2common/config.py | 11 ++++ st2common/st2common/models/api/keyvalue.py | 61 ++++++++++++++++++++++ st2common/st2common/models/db/keyvalue.py | 1 + 4 files changed, 74 insertions(+) diff --git a/st2common/in-requirements.txt b/st2common/in-requirements.txt index cdda29f4d6..7b7252c154 100644 --- a/st2common/in-requirements.txt +++ b/st2common/in-requirements.txt @@ -11,6 +11,7 @@ oslo.config paramiko pyyaml pymongo +python-keyczar requests retrying semver diff --git a/st2common/st2common/config.py b/st2common/st2common/config.py index baaed4b92c..0452ee87f5 100644 --- a/st2common/st2common/config.py +++ b/st2common/st2common/config.py @@ -139,6 +139,17 @@ def register_opts(ignore_errors=False): ] do_register_opts(api_opts, 'api', ignore_errors) + # Key Value store options + keyvalue_opts = [ + cfg.BoolOpt('enable_encryption', default=True, + help='Allow encryption of values in key value stored qualified as "secret".'), + cfg.StrOpt('encryption_key_path', default='conf/st2_kvstore_demo.crypto.key.json', + help='Location of the symmetric encryption key for encrypting values in ' + + 'kvstore. This key should be in JSON and should\'ve been ' + + 'generated using keyczar.') + ] + do_register_opts(keyvalue_opts, group='keyvalue') + # Common auth options auth_opts = [ cfg.StrOpt('api_url', default=None, diff --git a/st2common/st2common/models/api/keyvalue.py b/st2common/st2common/models/api/keyvalue.py index 05da0ea168..4e5146d085 100644 --- a/st2common/st2common/models/api/keyvalue.py +++ b/st2common/st2common/models/api/keyvalue.py @@ -14,16 +14,25 @@ # limitations under the License. import datetime +import json +import os +from keyczar.keys import AesKey +from oslo_config import cfg import six +from st2common.exceptions.keyvalue import CryptoKeyNotSetupException +from st2common.log import logging from st2common.util import isotime from st2common.util import date as date_utils +from st2common.util.crypto import symmetric_encrypt, symmetric_decrypt from st2common.models.api.base import BaseAPI from st2common.models.db.keyvalue import KeyValuePairDB +LOG = logging.getLogger(__name__) class KeyValuePairAPI(BaseAPI): + crypto_setup = False model = KeyValuePairDB schema = { 'type': 'object', @@ -44,6 +53,11 @@ class KeyValuePairAPI(BaseAPI): 'type': 'string', 'required': True }, + 'secret': { + 'type': 'boolean', + 'required': False, + 'default': False + }, 'expire_timestamp': { 'type': 'string', 'pattern': isotime.ISO8601_UTC_REGEX @@ -57,13 +71,47 @@ class KeyValuePairAPI(BaseAPI): 'additionalProperties': False } + @staticmethod + def _setup_crypto(): + LOG.info('Checking if encryption is enabled for key-value store.') + KeyValuePairAPI.is_encryption_enabled = cfg.CONF.keyvalue.enable_encryption + LOG.debug('Encryption enabled? : %s', KeyValuePairAPI.is_encryption_enabled) + if KeyValuePairAPI.is_encryption_enabled: + KeyValuePairAPI.crypto_key_path = cfg.CONF.keyvalue.encryption_key_path + LOG.info('Encryption enabled. Looking for key in path %s', + KeyValuePairAPI.crypto_key_path) + if not os.path.exists(KeyValuePairAPI.crypto_key_path): + msg = ('Encryption key file does not exist in path %s.' % + KeyValuePairAPI.crypto_key_path) + LOG.exception(msg) + LOG.info('All API requests will now send out BAD_REQUEST ' + + 'if you ask to store secrets in key value store.') + KeyValuePairAPI.crypto_key = None + else: + KeyValuePairAPI.crypto_key = KeyValuePairAPI._read_crypto_key( + KeyValuePairAPI.crypto_key_path + ) + KeyValuePairAPI.crypto_setup = True + + @staticmethod + def _read_crypto_key(key_path): + with open(key_path) as f: + key = AesKey.Read(f.read()) + return key + @classmethod def from_model(cls, model, mask_secrets=False): + if not KeyValuePairAPI.crypto_setup: + KeyValuePairAPI._setup_crypto() + doc = cls._from_model(model, mask_secrets=mask_secrets) if 'id' in doc: del doc['id'] + if not mask_secrets and model.encrypted: + doc[value] = symmetric_decrypt(KeyValuePairAPI.crypto_key, value) + if model.expire_timestamp: doc['expire_timestamp'] = isotime.format(model.expire_timestamp, offset=False) @@ -72,9 +120,13 @@ def from_model(cls, model, mask_secrets=False): @classmethod def to_model(cls, kvp): + if not KeyValuePairAPI.crypto_setup: + KeyValuePairAPI._setup_crypto() + name = getattr(kvp, 'name', None) description = getattr(kvp, 'description', None) value = kvp.value + encrypted = False if getattr(kvp, 'ttl', None): expire_timestamp = (date_utils.get_datetime_utc_now() + @@ -82,7 +134,16 @@ def to_model(cls, kvp): else: expire_timestamp = None + if getattr(kvp, 'secret', False): + if not KeyValuePairAPI.crypto_key: + msg = ('Crypto key not found in %s. Unable to encrypt value for key %s.' % + (KeyValuePairAPI.crypto_key_path, name)) + raise CryptoKeyNotSetupException(msg) + value = symmetric_encrypt(KeyValuePairAPI.crypto_key, value) + encrypted = True + model = cls.model(name=name, description=description, value=value, + encrypted=encrypted, expire_timestamp=expire_timestamp) return model diff --git a/st2common/st2common/models/db/keyvalue.py b/st2common/st2common/models/db/keyvalue.py index a03a4170d0..c705ee0ac3 100644 --- a/st2common/st2common/models/db/keyvalue.py +++ b/st2common/st2common/models/db/keyvalue.py @@ -36,6 +36,7 @@ class KeyValuePairDB(stormbase.StormBaseDB, stormbase.UIDFieldMixin): name = me.StringField(required=True, unique=True) value = me.StringField() + encrypted = me.BooleanField(default=False) expire_timestamp = me.DateTimeField() meta = { From 2060ad5cdce29510e8d4466f0fb5dbfdb1054340 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Tue, 19 Apr 2016 16:34:56 -0700 Subject: [PATCH 02/15] API Controller changes --- st2api/st2api/controllers/v1/keyvalue.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/st2api/st2api/controllers/v1/keyvalue.py b/st2api/st2api/controllers/v1/keyvalue.py index 85777c78b1..1e51c6e3cc 100644 --- a/st2api/st2api/controllers/v1/keyvalue.py +++ b/st2api/st2api/controllers/v1/keyvalue.py @@ -13,12 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from oslo_config import cfg from pecan import abort from pecan.rest import RestController import six from mongoengine import ValidationError from st2common import log as logging +from st2common.exceptions.keyvalue import CryptoKeyNotSetupException from st2common.models.api.keyvalue import KeyValuePairAPI from st2common.models.api.base import jsexpose from st2common.persistence.keyvalue import KeyValuePair @@ -113,7 +115,10 @@ def put(self, name, kvp): LOG.exception('Validation failed for key value data=%s', kvp) abort(http_client.BAD_REQUEST, str(e)) return - + except CryptoKeyNotSetupException as e: + LOG.exception(str(e)) + abort(http_client.BAD_REQUEST, str(e)) + return extra = {'kvp_db': kvp_db} LOG.audit('KeyValuePair updated. KeyValuePair.id=%s' % (kvp_db.id), extra=extra) From 75be11b450667e40ffe4d4505fc227c1f848c41b Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Tue, 19 Apr 2016 16:35:15 -0700 Subject: [PATCH 03/15] Utility methods --- st2common/st2common/exceptions/keyvalue.py | 24 ++++++++++++++++++++++ st2common/st2common/util/crypto.py | 22 ++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 st2common/st2common/exceptions/keyvalue.py create mode 100644 st2common/st2common/util/crypto.py diff --git a/st2common/st2common/exceptions/keyvalue.py b/st2common/st2common/exceptions/keyvalue.py new file mode 100644 index 0000000000..a31b66ccad --- /dev/null +++ b/st2common/st2common/exceptions/keyvalue.py @@ -0,0 +1,24 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 st2common.exceptions import StackStormBaseException + +__all__ = [ + 'CryptoKeyNotSetupException', +] + + +class CryptoKeyNotSetupException(StackStormBaseException): + pass diff --git a/st2common/st2common/util/crypto.py b/st2common/st2common/util/crypto.py new file mode 100644 index 0000000000..9af4c5ba40 --- /dev/null +++ b/st2common/st2common/util/crypto.py @@ -0,0 +1,22 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +def symmetric_encrypt(encrypt_key, message): + return encrypt_key.Encrypt(message) + + +def symmetric_decrypt(decrypt_key, crypto): + return decrypt_key.Decrypt(crypto) From dae6c18a83162bdd990b5eee3a75161f9118bdd7 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Wed, 20 Apr 2016 13:22:56 -0700 Subject: [PATCH 04/15] Convert crypto to actual string before saving it in db --- st2common/st2common/models/api/keyvalue.py | 2 +- st2common/st2common/util/crypto.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/st2common/st2common/models/api/keyvalue.py b/st2common/st2common/models/api/keyvalue.py index 4e5146d085..8d9e73bd8a 100644 --- a/st2common/st2common/models/api/keyvalue.py +++ b/st2common/st2common/models/api/keyvalue.py @@ -110,7 +110,7 @@ def from_model(cls, model, mask_secrets=False): del doc['id'] if not mask_secrets and model.encrypted: - doc[value] = symmetric_decrypt(KeyValuePairAPI.crypto_key, value) + doc['value'] = symmetric_decrypt(KeyValuePairAPI.crypto_key, model.value) if model.expire_timestamp: doc['expire_timestamp'] = isotime.format(model.expire_timestamp, offset=False) diff --git a/st2common/st2common/util/crypto.py b/st2common/st2common/util/crypto.py index 9af4c5ba40..addec90b40 100644 --- a/st2common/st2common/util/crypto.py +++ b/st2common/st2common/util/crypto.py @@ -13,10 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import binascii def symmetric_encrypt(encrypt_key, message): - return encrypt_key.Encrypt(message) + return binascii.hexlify(encrypt_key.Encrypt(message)).upper() def symmetric_decrypt(decrypt_key, crypto): - return decrypt_key.Decrypt(crypto) + return decrypt_key.Decrypt(binascii.unhexlify(crypto)) From 2ffa51fde11a716913dfc587b916a9d5d7397db8 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Wed, 20 Apr 2016 15:26:57 -0700 Subject: [PATCH 05/15] By default, GET /keys/secret_key returns encrypted value. --- st2api/st2api/controllers/v1/keyvalue.py | 11 ++++++++--- st2common/st2common/models/api/keyvalue.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/st2api/st2api/controllers/v1/keyvalue.py b/st2api/st2api/controllers/v1/keyvalue.py index 1e51c6e3cc..ed9e052ec2 100644 --- a/st2api/st2api/controllers/v1/keyvalue.py +++ b/st2api/st2api/controllers/v1/keyvalue.py @@ -46,8 +46,8 @@ def __init__(self): self._coordinator = coordination.get_coordinator() self.get_one_db_method = self.__get_by_name - @jsexpose(arg_types=[str]) - def get_one(self, name): + @jsexpose(arg_types=[str, str]) + def get_one(self, name, decrypt): """ List key by name. @@ -56,13 +56,18 @@ def get_one(self, name): """ kvp_db = self.__get_by_name(name=name) + if not decrypt: + decrypt = False + else: + decrypt = (decrypt == 'true' or decrypt == 'True' or decrypt == '1') + if not kvp_db: LOG.exception('Database lookup for name="%s" resulted in exception.', name) abort(http_client.NOT_FOUND) return try: - kvp_api = KeyValuePairAPI.from_model(kvp_db) + kvp_api = KeyValuePairAPI.from_model(kvp_db, mask_secrets=(not decrypt)) except (ValidationError, ValueError) as e: abort(http_client.INTERNAL_SERVER_ERROR, str(e)) return diff --git a/st2common/st2common/models/api/keyvalue.py b/st2common/st2common/models/api/keyvalue.py index 8d9e73bd8a..ecb64137ba 100644 --- a/st2common/st2common/models/api/keyvalue.py +++ b/st2common/st2common/models/api/keyvalue.py @@ -100,7 +100,7 @@ def _read_crypto_key(key_path): return key @classmethod - def from_model(cls, model, mask_secrets=False): + def from_model(cls, model, mask_secrets=True): if not KeyValuePairAPI.crypto_setup: KeyValuePairAPI._setup_crypto() From 69f82c4b13103e7fe72de51a10ef29d262db3e10 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Thu, 21 Apr 2016 15:15:01 -0700 Subject: [PATCH 06/15] Initial CLI changes - corresponding API touch ups --- st2api/st2api/controllers/v1/keyvalue.py | 6 ++-- st2client/st2client/commands/keyvalue.py | 21 +++++++++++++- st2client/st2client/commands/resource.py | 2 +- st2common/st2common/models/api/keyvalue.py | 33 ++++++++++++++-------- st2common/st2common/models/db/keyvalue.py | 2 +- st2common/st2common/util/crypto.py | 1 + 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/st2api/st2api/controllers/v1/keyvalue.py b/st2api/st2api/controllers/v1/keyvalue.py index ed9e052ec2..949a018840 100644 --- a/st2api/st2api/controllers/v1/keyvalue.py +++ b/st2api/st2api/controllers/v1/keyvalue.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from oslo_config import cfg from pecan import abort from pecan.rest import RestController import six @@ -47,20 +46,21 @@ def __init__(self): self.get_one_db_method = self.__get_by_name @jsexpose(arg_types=[str, str]) - def get_one(self, name, decrypt): + def get_one(self, name, decrypt='false'): """ List key by name. Handle: GET /keys/key1 """ - kvp_db = self.__get_by_name(name=name) if not decrypt: decrypt = False else: decrypt = (decrypt == 'true' or decrypt == 'True' or decrypt == '1') + kvp_db = self.__get_by_name(name=name) + if not kvp_db: LOG.exception('Database lookup for name="%s" resulted in exception.', name) abort(http_client.NOT_FOUND) diff --git a/st2client/st2client/commands/keyvalue.py b/st2client/st2client/commands/keyvalue.py index 9391a8e1eb..aed8b5cc44 100644 --- a/st2client/st2client/commands/keyvalue.py +++ b/st2client/st2client/commands/keyvalue.py @@ -82,7 +82,20 @@ def run_and_print(self, args, **kwargs): class KeyValuePairGetCommand(resource.ResourceGetCommand): pk_argument_name = 'name' - display_attributes = ['name', 'value', 'expire_timestamp'] + display_attributes = ['name', 'value', 'secret', 'encrypted', 'expire_timestamp'] + + def __init__(self, kv_resource, *args, **kwargs): + super(KeyValuePairGetCommand, self).__init__(kv_resource, *args, **kwargs) + self.parser.add_argument('-d', '--decrypt', action='store_true', + help='Decrypt secret if encrypted.') + + @resource.add_auth_token_to_kwargs_from_cli + def run(self, args, **kwargs): + resource_name = getattr(args, self.pk_argument_name, None) + decrypt = getattr(args, 'decrypt', False) + kwargs['params'] = {} + kwargs['params']['decrypt'] = str(decrypt).lower() + return self.get_resource_by_id(id=resource_name, **kwargs) class KeyValuePairSetCommand(resource.ResourceCommand): @@ -101,6 +114,9 @@ def __init__(self, resource, *args, **kwargs): self.parser.add_argument('value', help='Value paired with the key.') self.parser.add_argument('-l', '--ttl', dest='ttl', type=int, default=None, help='TTL (in seconds) for this value.') + self.parser.add_argument('-e', '--encrypt', dest='secret', + action='store_true', + help='Encrypt value before saving the value.') @add_auth_token_to_kwargs_from_cli def run(self, args, **kwargs): @@ -109,6 +125,9 @@ def run(self, args, **kwargs): instance.name = args.name instance.value = args.value + if args.secret: + instance.secret = args.secret + if args.ttl: instance.ttl = args.ttl diff --git a/st2client/st2client/commands/resource.py b/st2client/st2client/commands/resource.py index 0701cdb600..20960a0c50 100644 --- a/st2client/st2client/commands/resource.py +++ b/st2client/st2client/commands/resource.py @@ -287,7 +287,7 @@ def __init__(self, resource, *args, **kwargs): @add_auth_token_to_kwargs_from_cli def run(self, args, **kwargs): resource_id = getattr(args, self.pk_argument_name, None) - return self.get_resource(resource_id, **kwargs) + return self.get_resource_by_id(resource_id, **kwargs) def run_and_print(self, args, **kwargs): try: diff --git a/st2common/st2common/models/api/keyvalue.py b/st2common/st2common/models/api/keyvalue.py index ecb64137ba..1683c00f7b 100644 --- a/st2common/st2common/models/api/keyvalue.py +++ b/st2common/st2common/models/api/keyvalue.py @@ -14,7 +14,6 @@ # limitations under the License. import datetime -import json import os from keyczar.keys import AesKey @@ -31,6 +30,7 @@ LOG = logging.getLogger(__name__) + class KeyValuePairAPI(BaseAPI): crypto_setup = False model = KeyValuePairDB @@ -58,6 +58,11 @@ class KeyValuePairAPI(BaseAPI): 'required': False, 'default': False }, + 'encrypted': { + 'type': 'boolean', + 'required': False, + 'default': False + }, 'expire_timestamp': { 'type': 'string', 'pattern': isotime.ISO8601_UTC_REGEX @@ -82,7 +87,7 @@ def _setup_crypto(): KeyValuePairAPI.crypto_key_path) if not os.path.exists(KeyValuePairAPI.crypto_key_path): msg = ('Encryption key file does not exist in path %s.' % - KeyValuePairAPI.crypto_key_path) + KeyValuePairAPI.crypto_key_path) LOG.exception(msg) LOG.info('All API requests will now send out BAD_REQUEST ' + 'if you ask to store secrets in key value store.') @@ -95,8 +100,8 @@ def _setup_crypto(): @staticmethod def _read_crypto_key(key_path): - with open(key_path) as f: - key = AesKey.Read(f.read()) + with open(key_path) as key_file: + key = AesKey.Read(key_file.read()) return key @classmethod @@ -109,12 +114,18 @@ def from_model(cls, model, mask_secrets=True): if 'id' in doc: del doc['id'] - if not mask_secrets and model.encrypted: - doc['value'] = symmetric_decrypt(KeyValuePairAPI.crypto_key, model.value) - if model.expire_timestamp: doc['expire_timestamp'] = isotime.format(model.expire_timestamp, offset=False) + encrypted = False + if model.secret: + encrypted = True + + if not mask_secrets and model.secret: + doc['value'] = symmetric_decrypt(KeyValuePairAPI.crypto_key, model.value) + encrypted = False + + doc['encrypted'] = encrypted attrs = {attr: value for attr, value in six.iteritems(doc) if value is not None} return cls(**attrs) @@ -126,7 +137,7 @@ def to_model(cls, kvp): name = getattr(kvp, 'name', None) description = getattr(kvp, 'description', None) value = kvp.value - encrypted = False + secret = False if getattr(kvp, 'ttl', None): expire_timestamp = (date_utils.get_datetime_utc_now() + @@ -137,13 +148,13 @@ def to_model(cls, kvp): if getattr(kvp, 'secret', False): if not KeyValuePairAPI.crypto_key: msg = ('Crypto key not found in %s. Unable to encrypt value for key %s.' % - (KeyValuePairAPI.crypto_key_path, name)) + (KeyValuePairAPI.crypto_key_path, name)) raise CryptoKeyNotSetupException(msg) value = symmetric_encrypt(KeyValuePairAPI.crypto_key, value) - encrypted = True + secret = True model = cls.model(name=name, description=description, value=value, - encrypted=encrypted, + secret=secret, expire_timestamp=expire_timestamp) return model diff --git a/st2common/st2common/models/db/keyvalue.py b/st2common/st2common/models/db/keyvalue.py index c705ee0ac3..3bb18e11ec 100644 --- a/st2common/st2common/models/db/keyvalue.py +++ b/st2common/st2common/models/db/keyvalue.py @@ -36,7 +36,7 @@ class KeyValuePairDB(stormbase.StormBaseDB, stormbase.UIDFieldMixin): name = me.StringField(required=True, unique=True) value = me.StringField() - encrypted = me.BooleanField(default=False) + secret = me.BooleanField(default=False) expire_timestamp = me.DateTimeField() meta = { diff --git a/st2common/st2common/util/crypto.py b/st2common/st2common/util/crypto.py index addec90b40..3579982321 100644 --- a/st2common/st2common/util/crypto.py +++ b/st2common/st2common/util/crypto.py @@ -15,6 +15,7 @@ import binascii + def symmetric_encrypt(encrypt_key, message): return binascii.hexlify(encrypt_key.Encrypt(message)).upper() From 83a198193175310aa3bee6664f15a7029352a572 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Thu, 21 Apr 2016 15:15:18 -0700 Subject: [PATCH 07/15] Add a make target for building CLI --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 030e8d7c2e..30ba0699fb 100644 --- a/Makefile +++ b/Makefile @@ -319,6 +319,13 @@ packs-tests: requirements .packs-tests @echo . $(VIRTUALENV_DIR)/bin/activate; find ${ROOT_DIR}/contrib/* -maxdepth 0 -type d -print0 | xargs -0 -I FILENAME ./st2common/bin/st2-run-pack-tests -x -p FILENAME +.PHONY: cli +cli: + @echo + @echo "=================== Building st2 client ===================" + @echo + pushd $(CURDIR) && cd st2client && ((python setup.py develop || printf "\n\n!!! ERROR: BUILD FAILED !!!\n") || popd) + .PHONY: rpms rpms: @echo From 625606331945b5e0046f175dad67fbe5712cee06 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Thu, 21 Apr 2016 15:41:54 -0700 Subject: [PATCH 08/15] Support decrypt in list operation for keyvalue - API & CLI --- st2api/st2api/controllers/v1/keyvalue.py | 11 ++++++++++- st2client/st2client/commands/keyvalue.py | 12 ++++++++---- st2client/st2client/models/core.py | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/st2api/st2api/controllers/v1/keyvalue.py b/st2api/st2api/controllers/v1/keyvalue.py index 949a018840..73d7e38cb7 100644 --- a/st2api/st2api/controllers/v1/keyvalue.py +++ b/st2api/st2api/controllers/v1/keyvalue.py @@ -85,12 +85,21 @@ def get_all(self, **kw): # Prefix filtering prefix_filter = kw.get('prefix', None) + decrypt = kw.get('decrypt', None) + if not decrypt: + decrypt = False + else: + decrypt = (decrypt == 'true' or decrypt == 'True' or decrypt == '1') + del kw['decrypt'] + if prefix_filter: kw['name__startswith'] = prefix_filter del kw['prefix'] kvp_dbs = KeyValuePair.get_all(**kw) - kvps = [KeyValuePairAPI.from_model(kvp_db) for kvp_db in kvp_dbs] + kvps = [KeyValuePairAPI.from_model( + kvp_db, mask_secrets=(not decrypt)) for kvp_db in kvp_dbs + ] return kvps diff --git a/st2client/st2client/commands/keyvalue.py b/st2client/st2client/commands/keyvalue.py index aed8b5cc44..2126ccb004 100644 --- a/st2client/st2client/commands/keyvalue.py +++ b/st2client/st2client/commands/keyvalue.py @@ -57,7 +57,7 @@ def __init__(self, description, app, subparsers, parent_parser=None): class KeyValuePairListCommand(resource.ResourceListCommand): - display_attributes = ['name', 'value', 'expire_timestamp'] + display_attributes = ['name', 'value', 'secret', 'encrypted', 'expire_timestamp'] attribute_transform_functions = { 'expire_timestamp': format_isodate_for_user_timezone, } @@ -68,11 +68,16 @@ def __init__(self, *args, **kwargs): # Filter options self.parser.add_argument('--prefix', help=('Only return values which name starts with the ' ' provided prefix.')) + self.parser.add_argument('--decrypt', action='store_true', + help='Decrypt secrets and display plain text.') def run_and_print(self, args, **kwargs): if args.prefix: kwargs['prefix'] = args.prefix + decrypt = getattr(args, 'decrypt', False) + kwargs['params'] = {'decrypt': str(decrypt).lower()} + instances = self.run(args, **kwargs) self.print_output(reversed(instances), table.MultiColumnTable, attributes=args.attr, widths=args.width, @@ -87,14 +92,13 @@ class KeyValuePairGetCommand(resource.ResourceGetCommand): def __init__(self, kv_resource, *args, **kwargs): super(KeyValuePairGetCommand, self).__init__(kv_resource, *args, **kwargs) self.parser.add_argument('-d', '--decrypt', action='store_true', - help='Decrypt secret if encrypted.') + help='Decrypt secret if encrypted and show plain text.') @resource.add_auth_token_to_kwargs_from_cli def run(self, args, **kwargs): resource_name = getattr(args, self.pk_argument_name, None) decrypt = getattr(args, 'decrypt', False) - kwargs['params'] = {} - kwargs['params']['decrypt'] = str(decrypt).lower() + kwargs['params'] = {'decrypt': str(decrypt).lower()} return self.get_resource_by_id(id=resource_name, **kwargs) diff --git a/st2client/st2client/models/core.py b/st2client/st2client/models/core.py index ab9f6b9801..95d9636cb9 100644 --- a/st2client/st2client/models/core.py +++ b/st2client/st2client/models/core.py @@ -169,7 +169,7 @@ def get_all(self, **kwargs): prefix = kwargs.pop('prefix', None) user = kwargs.pop('user', None) - params = {} + params = kwargs.pop('params', {}) if limit and limit <= 0: limit = None if limit: From c5ad5b3dcce1e3900504fb369b0346fe33adb285 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Fri, 22 Apr 2016 16:34:11 -0700 Subject: [PATCH 09/15] First set of tests --- conf/st2_kvstore_demo.crypto.key.json | 1 + st2api/tests/unit/controllers/v1/test_kvps.py | 52 +++++++++++++++++++ st2common/st2common/util/crypto.py | 30 +++++++++++ st2common/tests/unit/test_crypto_utils.py | 44 ++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 conf/st2_kvstore_demo.crypto.key.json create mode 100644 st2common/tests/unit/test_crypto_utils.py diff --git a/conf/st2_kvstore_demo.crypto.key.json b/conf/st2_kvstore_demo.crypto.key.json new file mode 100644 index 0000000000..83d2369afd --- /dev/null +++ b/conf/st2_kvstore_demo.crypto.key.json @@ -0,0 +1 @@ +{"hmacKey": {"hmacKeyString": "685EWJ8U2U_c3Ulv9ibB6aa0Y9aYm-0bCwe2mqjLJuw", "size": 256}, "aesKeyString": "eKGFrfNEUhAgX0uofaU1wQ", "mode": "CBC", "size": 128} \ No newline at end of file diff --git a/st2api/tests/unit/controllers/v1/test_kvps.py b/st2api/tests/unit/controllers/v1/test_kvps.py index 9f9498543e..85067593e4 100644 --- a/st2api/tests/unit/controllers/v1/test_kvps.py +++ b/st2api/tests/unit/controllers/v1/test_kvps.py @@ -31,6 +31,12 @@ 'ttl': 10 } +SECRET_KVP = { + 'name': 'secret_key1', + 'value': 'secret_value1', + 'secret': True +} + class TestKeyValuePairController(FunctionalTest): @@ -86,6 +92,52 @@ def test_put_with_ttl(self): self.assertTrue(get_resp.json[0]['expire_timestamp']) self.__do_delete(self.__get_kvp_id(put_resp)) + def test_put_secret(self): + put_resp = self.__do_put('secret_key1', SECRET_KVP) + kvp_id = self.__get_kvp_id(put_resp) + get_resp = self.__do_get_one(kvp_id) + self.assertTrue(get_resp.json['encrypted']) + crypto_val = get_resp.json['value'] + self.assertNotEqual(SECRET_KVP['value'], crypto_val) + self.__do_delete(self.__get_kvp_id(put_resp)) + + def test_get_one_secret_no_decrypt(self): + put_resp = self.__do_put('secret_key1', SECRET_KVP) + kvp_id = self.__get_kvp_id(put_resp) + get_resp = self.app.get('/v1/keys/secret_key1') + self.assertEqual(get_resp.status_int, 200) + self.assertEqual(self.__get_kvp_id(get_resp), kvp_id) + self.assertTrue(get_resp.json['secret']) + self.assertTrue(get_resp.json['encrypted']) + self.__do_delete(kvp_id) + + def test_get_one_secret_decrypt(self): + put_resp = self.__do_put('secret_key1', SECRET_KVP) + kvp_id = self.__get_kvp_id(put_resp) + get_resp = self.app.get('/v1/keys/secret_key1?decrypt=true') + self.assertEqual(get_resp.status_int, 200) + self.assertEqual(self.__get_kvp_id(get_resp), kvp_id) + self.assertTrue(get_resp.json['secret']) + self.assertFalse(get_resp.json['encrypted']) + self.assertEqual(get_resp.json['value'], SECRET_KVP['value']) + self.__do_delete(kvp_id) + + def test_get_all_decrypt(self): + put_resp = self.__do_put('secret_key1', SECRET_KVP) + kvp_id_1 = self.__get_kvp_id(put_resp) + put_resp = self.__do_put('key1', KVP) + kvp_id_2 = self.__get_kvp_id(put_resp) + kvps = {'key1': KVP, 'secret_key1': SECRET_KVP} + stored_kvps = self.app.get('/v1/keys?decrypt=true').json + self.assertTrue(len(stored_kvps), 2) + for stored_kvp in stored_kvps: + self.assertFalse(stored_kvp['encrypted']) + exp_kvp = kvps.get(stored_kvp['name']) + self.assertTrue(exp_kvp is not None) + self.assertEqual(exp_kvp['value'], stored_kvp['value']) + self.__do_delete(kvp_id_1) + self.__do_delete(kvp_id_2) + def test_put_delete(self): put_resp = self.__do_put('key1', KVP) self.assertEqual(put_resp.status_int, 200) diff --git a/st2common/st2common/util/crypto.py b/st2common/st2common/util/crypto.py index 3579982321..c429489490 100644 --- a/st2common/st2common/util/crypto.py +++ b/st2common/st2common/util/crypto.py @@ -17,8 +17,38 @@ def symmetric_encrypt(encrypt_key, message): + """ + Encrypt the given message using the encrypt_key. Returns a UTF-8 str + ready to be stored in database. Note that we convert the hex notation + to a ASCII notation to produce a UTF-8 friendly string. + + Also, this method will not return the same output on multiple invocations + of same method. The reason is that the Encrypt method uses a different + 'Initialization Vector' per run and the IV is part of the output. + + :param encrypt_key: Symmetric AES key to use for encryption. + :type encrypt_key: :class:`keyczar.keys.AesKey` + + :param message: Message to be encrypted. + :type message: ``str`` + + :rtype: ``str`` + """ return binascii.hexlify(encrypt_key.Encrypt(message)).upper() def symmetric_decrypt(decrypt_key, crypto): + """ + Decrypt the given crypto text into plain text. Returns the original + string input. Note that we first convert the string to hex notation + and then decrypt. This is reverse of the encrypt operation. + + :param decrypt_key: Symmetric AES key to use for decryption. + :type decrypt_key: :class:`keyczar.keys.AesKey` + + :param crypto: Crypto text to be decrypted. + :type crypto: ``str`` + + :rtype: ``str`` + """ return decrypt_key.Decrypt(binascii.unhexlify(crypto)) diff --git a/st2common/tests/unit/test_crypto_utils.py b/st2common/tests/unit/test_crypto_utils.py new file mode 100644 index 0000000000..972f53758c --- /dev/null +++ b/st2common/tests/unit/test_crypto_utils.py @@ -0,0 +1,44 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 keyczar.keys import AesKey +from unittest2 import TestCase + + +import st2common.util.crypto as crypto_utils + + +class CryptoUtilsTestCase(TestCase): + + @classmethod + def setUpClass(cls): + super(CryptoUtilsTestCase, cls).setUpClass() + CryptoUtilsTestCase.test_crypto_key = AesKey.Generate() + + def test_symmetric_encrypt_decrypt(self): + original = 'secret' + crypto = crypto_utils.symmetric_encrypt(CryptoUtilsTestCase.test_crypto_key, original) + plain = crypto_utils.symmetric_decrypt(CryptoUtilsTestCase.test_crypto_key, crypto) + self.assertEqual(plain, original) + + def test_encrypt_output_is_diff_due_to_diff_IV(self): + original = 'Kami is a little boy.' + cryptos = set() + + for _ in xrange(0, 10000): + crypto = crypto_utils.symmetric_encrypt(CryptoUtilsTestCase.test_crypto_key, + original) + self.assertTrue(crypto not in cryptos) + cryptos.add(crypto) From 09fe92d812fb692dd9f8ad2c760bf6789d1abd7d Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Fri, 22 Apr 2016 16:46:44 -0700 Subject: [PATCH 10/15] Add db model test for kv --- st2common/tests/unit/test_keyvalue_lookup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/st2common/tests/unit/test_keyvalue_lookup.py b/st2common/tests/unit/test_keyvalue_lookup.py index 2147af99d0..5d6a006c0a 100644 --- a/st2common/tests/unit/test_keyvalue_lookup.py +++ b/st2common/tests/unit/test_keyvalue_lookup.py @@ -57,3 +57,16 @@ def test_missing_key_lookup(self): lookup = KeyValueLookup() self.assertEquals(str(lookup.missing_key), '') self.assertTrue(lookup.missing_key, 'Should be not none.') + + def test_secret_lookup(self): + secret_value = '0055A2D9A09E1071931925933744965EEA7E23DCF59A8D1D7A3' + \ + '64338294916D37E83C4796283C584751750E39844E2FD97A3727DB5D553F638' + k1 = KeyValuePair.add_or_update(KeyValuePairDB( + name='k1', value=secret_value, + secret=True, encrypted=True) + ) + k2 = KeyValuePair.add_or_update(KeyValuePairDB(name='k2', value='v2')) + + lookup = KeyValueLookup() + self.assertEquals(str(lookup.k1), k1.value) + self.assertEquals(str(lookup.k2), k2.value) From c4ac445da8c11770276e6cf4682bf4d54c60eba7 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Mon, 25 Apr 2016 11:43:47 -0700 Subject: [PATCH 11/15] Fix st2client tests --- st2client/st2client/models/core.py | 3 ++- st2client/tests/unit/test_commands.py | 16 +--------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/st2client/st2client/models/core.py b/st2client/st2client/models/core.py index 95d9636cb9..d8f99de762 100644 --- a/st2client/st2client/models/core.py +++ b/st2client/st2client/models/core.py @@ -240,7 +240,8 @@ def query(self, **kwargs): if 'limit' in kwargs and kwargs.get('limit') <= 0: kwargs.pop('limit') token = kwargs.get('token', None) - params = {} + params = kwargs.get('params', {}) + print(params) for k, v in six.iteritems(kwargs): if k != 'token': params[k] = v diff --git a/st2client/tests/unit/test_commands.py b/st2client/tests/unit/test_commands.py index 60803f5142..e4cf9961b3 100644 --- a/st2client/tests/unit/test_commands.py +++ b/st2client/tests/unit/test_commands.py @@ -72,23 +72,9 @@ def test_command_get_by_id(self): expected = json.loads(json.dumps(base.RESOURCES[0])) self.assertEqual(actual, expected) - @mock.patch.object( - models.ResourceManager, 'get_by_name', - mock.MagicMock(return_value=base.FakeResource(**base.RESOURCES[0]))) - @mock.patch.object( - models.ResourceManager, 'get_by_id', - mock.MagicMock(return_value=None)) - def test_command_get_by_name(self): - args = self.parser.parse_args(['fakeresource', 'get', 'abc']) - self.assertEqual(args.func, self.branch.commands['get'].run_and_print) - instance = self.branch.commands['get'].run(args) - actual = instance.serialize() - expected = json.loads(json.dumps(base.RESOURCES[0])) - self.assertEqual(actual, expected) - @mock.patch.object( httpclient.HTTPClient, 'get', - mock.MagicMock(return_value=base.FakeResponse(json.dumps([base.RESOURCES[0]]), 200, 'OK'))) + mock.MagicMock(return_value=base.FakeResponse(json.dumps(base.RESOURCES[0]), 200, 'OK'))) def test_command_get(self): args = self.parser.parse_args(['fakeresource', 'get', 'abc']) self.assertEqual(args.func, self.branch.commands['get'].run_and_print) From 249340e7b9fdf9b72311a7c4f51ef38fc0ee6778 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Mon, 25 Apr 2016 13:59:03 -0700 Subject: [PATCH 12/15] Tool to generate secure key --- tools/st2-generate-symmetric-crypto-key.py | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100755 tools/st2-generate-symmetric-crypto-key.py diff --git a/tools/st2-generate-symmetric-crypto-key.py b/tools/st2-generate-symmetric-crypto-key.py new file mode 100755 index 0000000000..b2f157e999 --- /dev/null +++ b/tools/st2-generate-symmetric-crypto-key.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +import argparse +import datetime +import os +import shutil +import sys +import traceback + +from keyczar.keys import AesKey + + +def _backup_old_key(key_path): + base_path = os.path.dirname(key_path) + dt_str = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ") + bkup_file_name = os.path.basename(key_path) + '.bkup-%s' % dt_str + shutil.move(key_path, os.path.join(base_path, bkup_file_name)) + print('WARNING: Backed up old key file to %s.' % bkup_file_name) + + +def main(key_path, force=False): + base_path = os.path.dirname(key_path) + if not os.access(base_path, os.W_OK): + print('ERROR: You do not have sufficient permissions to write to path: %s.' % key_path) + print('Try setting up permissions correctly and then run this tool.') + sys.exit(1) + + if os.path.exists(key_path): + print('You already have a key at the specified location %s!' % key_path) + + if not force: + print('Not generating a new key. Either delete the file or re-run with --force.') + sys.exit(2) + else: + try: + _backup_old_key(key_path) + except: + traceback.print_exc() + print('WARNING: Failed backing up old key! Ignoring!') + + print('Generating new key...') + + with open(key_path, 'w') as key_file: + k = AesKey.Generate() + key_file.write(str(k)) + key_file.flush() + + msg = ('Key written to %s. ' % key_path + 'Secure the permissions so only StackStorm API ' + + 'process and StackStorm admin access the file.') + print(msg) + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Tool for crypto key generation.') + parser.add_argument('-k', '--key-path', + required=True, + help='Path to file to write key to. Secure permissions of file so ' + + 'only admin can read the crypto key.') + parser.add_argument('-f', '--force', action='store_true', + help='Force rewrite the key file if already exists.') + + args = parser.parse_args() + main(key_path=args.key_path, force=args.force) From 5bb6c67bb590a82a8b7a7b6d9c4df780afb977eb Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Mon, 25 Apr 2016 14:16:54 -0700 Subject: [PATCH 13/15] Remove backup option --- tools/st2-generate-symmetric-crypto-key.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/tools/st2-generate-symmetric-crypto-key.py b/tools/st2-generate-symmetric-crypto-key.py index b2f157e999..fb1ec16260 100755 --- a/tools/st2-generate-symmetric-crypto-key.py +++ b/tools/st2-generate-symmetric-crypto-key.py @@ -10,14 +10,6 @@ from keyczar.keys import AesKey -def _backup_old_key(key_path): - base_path = os.path.dirname(key_path) - dt_str = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ") - bkup_file_name = os.path.basename(key_path) + '.bkup-%s' % dt_str - shutil.move(key_path, os.path.join(base_path, bkup_file_name)) - print('WARNING: Backed up old key file to %s.' % bkup_file_name) - - def main(key_path, force=False): base_path = os.path.dirname(key_path) if not os.access(base_path, os.W_OK): @@ -31,14 +23,8 @@ def main(key_path, force=False): if not force: print('Not generating a new key. Either delete the file or re-run with --force.') sys.exit(2) - else: - try: - _backup_old_key(key_path) - except: - traceback.print_exc() - print('WARNING: Failed backing up old key! Ignoring!') - - print('Generating new key...') + + print('WARNING: Rewriting existing key with new key!') with open(key_path, 'w') as key_file: k = AesKey.Generate() From 195b1bef7d308dfa985a9a5a7bd7c7ea955d611f Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Mon, 25 Apr 2016 14:27:46 -0700 Subject: [PATCH 14/15] Cleanup imports --- tools/st2-generate-symmetric-crypto-key.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tools/st2-generate-symmetric-crypto-key.py b/tools/st2-generate-symmetric-crypto-key.py index fb1ec16260..74ec46bb40 100755 --- a/tools/st2-generate-symmetric-crypto-key.py +++ b/tools/st2-generate-symmetric-crypto-key.py @@ -1,11 +1,8 @@ #!/usr/bin/env python import argparse -import datetime import os -import shutil import sys -import traceback from keyczar.keys import AesKey From 1ffa9e6d7cb41aff6513f037fd7232a0f5b0eb18 Mon Sep 17 00:00:00 2001 From: Lakshmi Kannan Date: Mon, 25 Apr 2016 16:41:21 -0700 Subject: [PATCH 15/15] Tests and dev env to supply key file, default is no key file --- conf/st2.dev.conf | 3 +++ st2common/st2common/config.py | 2 +- st2tests/conf/st2_kvstore_tests.crypto.key.json | 1 + st2tests/st2tests/config.py | 7 +++++++ 4 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 st2tests/conf/st2_kvstore_tests.crypto.key.json diff --git a/conf/st2.dev.conf b/conf/st2.dev.conf index 808a63733c..4c376877e3 100644 --- a/conf/st2.dev.conf +++ b/conf/st2.dev.conf @@ -11,6 +11,9 @@ mask_secrets = False # Development vagrant VM is setup to run on 172.168.50.50. allow_origin = http://172.168.50.50:3000 +[keyvalue] +encryption_key_path = conf/st2_kvstore_demo.crypto.key.json + [stream] # Host and port to bind the API server. host = 0.0.0.0 diff --git a/st2common/st2common/config.py b/st2common/st2common/config.py index 0452ee87f5..5ad46712f8 100644 --- a/st2common/st2common/config.py +++ b/st2common/st2common/config.py @@ -143,7 +143,7 @@ def register_opts(ignore_errors=False): keyvalue_opts = [ cfg.BoolOpt('enable_encryption', default=True, help='Allow encryption of values in key value stored qualified as "secret".'), - cfg.StrOpt('encryption_key_path', default='conf/st2_kvstore_demo.crypto.key.json', + cfg.StrOpt('encryption_key_path', default='', help='Location of the symmetric encryption key for encrypting values in ' + 'kvstore. This key should be in JSON and should\'ve been ' + 'generated using keyczar.') diff --git a/st2tests/conf/st2_kvstore_tests.crypto.key.json b/st2tests/conf/st2_kvstore_tests.crypto.key.json new file mode 100644 index 0000000000..83d2369afd --- /dev/null +++ b/st2tests/conf/st2_kvstore_tests.crypto.key.json @@ -0,0 +1 @@ +{"hmacKey": {"hmacKeyString": "685EWJ8U2U_c3Ulv9ibB6aa0Y9aYm-0bCwe2mqjLJuw", "size": 256}, "aesKeyString": "eKGFrfNEUhAgX0uofaU1wQ", "mode": "CBC", "size": 128} \ No newline at end of file diff --git a/st2tests/st2tests/config.py b/st2tests/st2tests/config.py index 2c03c0afb7..84d8705835 100644 --- a/st2tests/st2tests/config.py +++ b/st2tests/st2tests/config.py @@ -46,6 +46,7 @@ def _override_config_opts(): _override_db_opts() _override_common_opts() _override_api_opts() + _override_keyvalue_opts() def _register_config_opts(): @@ -81,6 +82,12 @@ def _override_api_opts(): group='api') +def _override_keyvalue_opts(): + CONF.set_override(name='encryption_key_path', + override='st2tests/conf/st2_kvstore_tests.crypto.key.json', + group='keyvalue') + + def _register_common_opts(): try: common_config.register_opts(ignore_errors=True)