Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Added ability to pass in encrypted key/values into the CLI and API #4547

Merged
merged 16 commits into from
Mar 19, 2019
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
15 changes: 15 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ Added

* Adding ``Cache-Control`` header to all API responses so clients will favor
refresh from API instead of using cached version.
* Add new ``--encrypted`` flag to ``st2 key set`` CLI command that allows users to pass in values
which are already encrypted.

This attribute signals the API that the value is already encrypted and should be used as-is.

``st2 key load`` CLI command has also been updated so it knows how to work with values which are
already encrypted. This means that ``st2 key list -n 100 -j < data.json ; st2 key load
data.json`` will now also work out of the box for encrypted datastore values (value which have
``encrypted: True`` and ``secret: True`` attributes will be treated as pre-encrypted values as
treated as such).

The most common use case for this feature is migrating / restoring datastore values from one
StackStorm instance to another which uses the same crypto key.

Contributed by Nick Maludy (Encore Technologies)

Changed
~~~~~~~
Expand Down
11 changes: 11 additions & 0 deletions st2api/st2api/controllers/v1/keyvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ def put(self, kvp, name, requester_user, scope=FULL_SYSTEM_SCOPE):
user=user,
require_rbac=True)

# Validate that encrypted option can only be used by admins
encrypted = getattr(kvp, 'encrypted', False)
self._validate_encrypted_query_parameter(encrypted=encrypted, scope=scope,
requester_user=requester_user)

key_ref = get_key_reference(scope=scope, name=name, user=user)
lock_name = self._get_lock_name_for_key(name=key_ref, scope=scope)
LOG.debug('PUT scope: %s, name: %s', scope, name)
Expand Down Expand Up @@ -409,6 +414,12 @@ def _validate_decrypt_query_parameter(self, decrypt, scope, requester_user):
msg = 'Decrypt option requires administrator access'
raise AccessDeniedError(message=msg, user_db=requester_user)

def _validate_encrypted_query_parameter(self, encrypted, scope, requester_user):
is_admin = rbac_utils.user_is_admin(user_db=requester_user)
if encrypted and not is_admin:
msg = 'Pre-encrypted option requires administrator access'
raise AccessDeniedError(message=msg, user_db=requester_user)

def _validate_scope(self, scope):
if scope not in ALLOWED_SCOPES:
msg = 'Scope %s is not in allowed scopes list: %s.' % (scope, ALLOWED_SCOPES)
Expand Down
94 changes: 94 additions & 0 deletions st2api/tests/unit/controllers/v1/test_kvps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import copy

from tests import FunctionalTest

from st2common.models.db.auth import UserDB
Expand Down Expand Up @@ -70,6 +72,23 @@
'secret': True
}

# value = S3cret!Value
# encrypted with st2tests/conf/st2_kvstore_tests.crypto.key.json
ENCRYPTED_KVP = {
'name': 'secret_key1',
'value': ('3030303030298D848B45A24EDCD1A82FAB4E831E3FCE6E60956817A48A180E4C040801E'
'B30170DACF79498F30520236A629912C3584847098D'),
'encrypted': True
}

ENCRYPTED_KVP_SECRET_FALSE = {
'name': 'secret_key2',
'value': ('3030303030298D848B45A24EDCD1A82FAB4E831E3FCE6E60956817A48A180E4C040801E'
'B30170DACF79498F30520236A629912C3584847098D'),
'secret': True,
'encrypted': True
}


class KeyValuePairControllerTestCase(FunctionalTest):

Expand Down Expand Up @@ -468,6 +487,81 @@ def test_get_all_decrypt(self):
self.__do_delete(kvp_id_1)
self.__do_delete(kvp_id_2)

def test_put_encrypted_value(self):
# 1. encrypted=True, secret=True
put_resp = self.__do_put('secret_key1', ENCRYPTED_KVP)
kvp_id = self.__get_kvp_id(put_resp)

# Verify there is no secrets leakage
self.assertEqual(put_resp.status_code, 200)
self.assertEqual(put_resp.json['name'], 'secret_key1')
self.assertEqual(put_resp.json['scope'], 'st2kv.system')
self.assertEqual(put_resp.json['encrypted'], True)
self.assertEqual(put_resp.json['secret'], True)
self.assertEqual(put_resp.json['value'], ENCRYPTED_KVP['value'])
self.assertTrue(put_resp.json['value'] != 'S3cret!Value')
self.assertTrue(len(put_resp.json['value']) > len('S3cret!Value') * 2)

get_resp = self.__do_get_one(kvp_id + '?decrypt=True')
self.assertEqual(put_resp.json['name'], 'secret_key1')
self.assertEqual(put_resp.json['scope'], 'st2kv.system')
self.assertEqual(put_resp.json['encrypted'], True)
self.assertEqual(put_resp.json['secret'], True)
self.assertEqual(put_resp.json['value'], ENCRYPTED_KVP['value'])

# Verify data integrity post decryption
get_resp = self.__do_get_one(kvp_id + '?decrypt=True')
self.assertFalse(get_resp.json['encrypted'])
self.assertEqual(get_resp.json['value'], 'S3cret!Value')
self.__do_delete(self.__get_kvp_id(put_resp))

# 2. encrypted=True, secret=False
# encrypted should always imply secret=True
put_resp = self.__do_put('secret_key2', ENCRYPTED_KVP_SECRET_FALSE)
kvp_id = self.__get_kvp_id(put_resp)

# Verify there is no secrets leakage
self.assertEqual(put_resp.status_code, 200)
self.assertEqual(put_resp.json['name'], 'secret_key2')
self.assertEqual(put_resp.json['scope'], 'st2kv.system')
self.assertEqual(put_resp.json['encrypted'], True)
self.assertEqual(put_resp.json['secret'], True)
self.assertEqual(put_resp.json['value'], ENCRYPTED_KVP['value'])
self.assertTrue(put_resp.json['value'] != 'S3cret!Value')
self.assertTrue(len(put_resp.json['value']) > len('S3cret!Value') * 2)

get_resp = self.__do_get_one(kvp_id + '?decrypt=True')
self.assertEqual(put_resp.json['name'], 'secret_key2')
self.assertEqual(put_resp.json['scope'], 'st2kv.system')
self.assertEqual(put_resp.json['encrypted'], True)
self.assertEqual(put_resp.json['secret'], True)
self.assertEqual(put_resp.json['value'], ENCRYPTED_KVP['value'])

# Verify data integrity post decryption
get_resp = self.__do_get_one(kvp_id + '?decrypt=True')
self.assertFalse(get_resp.json['encrypted'])
self.assertEqual(get_resp.json['value'], 'S3cret!Value')
self.__do_delete(self.__get_kvp_id(put_resp))

def test_put_encrypted_value_integrity_check_failed(self):
data = copy.deepcopy(ENCRYPTED_KVP)
data['value'] = 'corrupted'
put_resp = self.__do_put('secret_key1', data, expect_errors=True)
self.assertEqual(put_resp.status_code, 400)

expected_error = ('Failed to verify the integrity of the provided value for key '
'"secret_key1".')
self.assertTrue(expected_error in put_resp.json['faultstring'])

data = copy.deepcopy(ENCRYPTED_KVP)
data['value'] = str(data['value'][:-2])
put_resp = self.__do_put('secret_key1', data, expect_errors=True)
self.assertEqual(put_resp.status_code, 400)

expected_error = ('Failed to verify the integrity of the provided value for key '
'"secret_key1".')
self.assertTrue(expected_error in put_resp.json['faultstring'])

def test_put_delete(self):
put_resp = self.__do_put('key1', KVP)
self.assertEqual(put_resp.status_int, 200)
Expand Down
29 changes: 25 additions & 4 deletions st2client/st2client/commands/keyvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def __init__(self, resource, *args, **kwargs):
# Filter options
self.parser.add_argument('--prefix', help=('Only return values with names starting with '
'the provided prefix.'))
self.parser.add_argument('--decrypt', action='store_true',
self.parser.add_argument('-d', '--decrypt', action='store_true',
help='Decrypt secrets and displays plain text.')
self.parser.add_argument('-s', '--scope', default=DEFAULT_LIST_SCOPE, dest='scope',
help='Scope item is under. Example: "user".')
Expand Down Expand Up @@ -159,15 +159,24 @@ def __init__(self, resource, *args, **kwargs):
*args, **kwargs
)

# --encrypt and --encrypted options are mutually exclusive.
# --encrypt implies provided value is plain text and should be encrypted whereas
# --encrypted implies value is already encrypted and should be treated as-is.
encryption_group = self.parser.add_mutually_exclusive_group()
encryption_group.add_argument('-e', '--encrypt', dest='secret',
action='store_true',
help='Encrypt value before saving.')
encryption_group.add_argument('--encrypted', dest='encrypted',
action='store_true',
help=('Value provided is already encrypted with the '
'instance crypto key and should be stored as-is.'))

self.parser.add_argument('name',
metavar='name',
help='Name of the key value pair.')
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.')
self.parser.add_argument('-s', '--scope', dest='scope', default=DEFAULT_CUD_SCOPE,
help='Specify the scope under which you want ' +
'to place the item.')
Expand All @@ -186,6 +195,9 @@ def run(self, args, **kwargs):
if args.secret:
instance.secret = args.secret

if args.encrypted:
instance.encrypted = args.encrypted

if args.ttl:
instance.ttl = args.ttl

Expand Down Expand Up @@ -312,6 +324,7 @@ def run(self, args, **kwargs):
# parse optional KeyValuePair properties
scope = item.get('scope', DEFAULT_CUD_SCOPE)
user = item.get('user', None)
encrypted = item.get('encrypted', False)
secret = item.get('secret', False)
ttl = item.get('ttl', None)

Expand All @@ -332,13 +345,21 @@ def run(self, args, **kwargs):
instance.name = name
instance.value = value
instance.scope = scope

if user:
instance.user = user
if encrypted:
instance.encrypted = encrypted
if secret:
instance.secret = secret
if ttl:
instance.ttl = ttl

# encrypted=True and secret=True implies that the value is already encrypted and should
# be used as such
if encrypted and secret:
instance.encrypted = True

# call the API to create/update the KeyValuePair
self.manager.update(instance, **kwargs)
instances.append(instance)
Expand Down
56 changes: 55 additions & 1 deletion st2client/tests/unit/test_keyvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
'secret': True
}

KEYVALUE_PRE_ENCRYPTED = {
'id': 'kv_name',
'name': 'kv_name.',
'value': 'AAABBBCCC1234',
'scope': 'system',
'encrypted': True,
'secret': True
}

KEYVALUE_TTL = {
'id': 'kv_name',
'name': 'kv_name.',
Expand All @@ -69,10 +78,11 @@
KEYVALUE_ALL = {
'id': 'kv_name',
'name': 'kv_name.',
'value': 'super cool value',
'value': 'AAAAABBBBBCCCCCCDDDDD11122345',
'scope': 'system',
'user': 'stanley',
'secret': True,
'encrypted': True,
'ttl': 100
}

Expand Down Expand Up @@ -108,6 +118,31 @@ def tearDown(self):
super(TestKeyValueBase, self).tearDown()


class TestKeyValueSet(TestKeyValueBase):

@mock.patch.object(
requests, 'put',
mock.MagicMock(return_value=base.FakeResponse(json.dumps(KEYVALUE_PRE_ENCRYPTED), 200,
'OK')))
def test_set_keyvalue(self):
"""Test setting key/value pair with optional pre_encrypted field
"""
args = ['key', 'set', '--encrypted', 'kv_name', 'AAABBBCCC1234']
retcode = self.shell.run(args)
self.assertEqual(retcode, 0)

def test_encrypt_and_encrypted_flags_are_mutually_exclusive(self):
args = ['key', 'set', '--encrypt', '--encrypted', 'kv_name', 'AAABBBCCC1234']

self.assertRaisesRegexp(SystemExit, '2', self.shell.run, args)

self.stderr.seek(0)
stderr = self.stderr.read()

expected_msg = ('error: argument --encrypted: not allowed with argument -e/--encrypt')
self.assertTrue(expected_msg in stderr)


class TestKeyValueLoad(TestKeyValueBase):

@mock.patch.object(
Expand Down Expand Up @@ -182,6 +217,25 @@ def test_load_keyvalue_secret(self):
os.close(fd)
os.unlink(path)

@mock.patch.object(
requests, 'put',
mock.MagicMock(return_value=base.FakeResponse(json.dumps(KEYVALUE_PRE_ENCRYPTED), 200,
'OK')))
def test_load_keyvalue_already_encrypted(self):
"""Test loading of key/value pair with the pre-encrypted value
"""
fd, path = tempfile.mkstemp(suffix='.json')
try:
with open(path, 'a') as f:
f.write(json.dumps(KEYVALUE_PRE_ENCRYPTED, indent=4))

args = ['key', 'load', path]
retcode = self.shell.run(args)
self.assertEqual(retcode, 0)
finally:
os.close(fd)
os.unlink(path)

@mock.patch.object(
requests, 'put',
mock.MagicMock(return_value=base.FakeResponse(json.dumps(KEYVALUE_TTL), 200, 'OK')))
Expand Down
43 changes: 38 additions & 5 deletions st2common/st2common/models/api/keyvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def to_model(cls, kvp):
name = getattr(kvp, 'name', None)
description = getattr(kvp, 'description', None)
value = kvp.value
original_value = value
secret = False

if getattr(kvp, 'ttl', None):
Expand All @@ -165,13 +166,32 @@ def to_model(cls, kvp):
else:
expire_timestamp = None

encrypted = getattr(kvp, 'encrypted', False)
secret = getattr(kvp, 'secret', False)

if secret:
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)
# If user transmitted the value in an pre-encrypted format, we perform the decryption here
# to ensure data integrity. Besides that, we store data as-is.
# Keep in mind that encrypted=True also always implies secret=True. If we didn't do
# that and supported encrypted=True, secret=False, this would allow users to decrypt
# any encrypted value.
if encrypted:
secret = True

cls._verif_key_is_set_up(name=name)

try:
symmetric_decrypt(KeyValuePairAPI.crypto_key, value)
except Exception:
msg = ('Failed to verify the integrity of the provided value for key "%s". Ensure '
'that the value is encrypted with the correct key and not corrupted.' %
(name))
raise ValueError(msg)
Copy link
Contributor

Choose a reason for hiding this comment

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

I may have missed it but I don't see a unit test that shows failure in checking integrity.

Copy link
Member

Choose a reason for hiding this comment

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

Good catch - I did plan to add that one, but forgot to.

Will add it.

Copy link
Member

Choose a reason for hiding this comment

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

Added in 17427bf.


# Additional safety check to ensure that the value hasn't been decrypted
assert value == original_value
elif secret:
cls._verif_key_is_set_up(name=name)

value = symmetric_encrypt(KeyValuePairAPI.crypto_key, value)

scope = getattr(kvp, 'scope', FULL_SYSTEM_SCOPE)
Expand All @@ -181,12 +201,25 @@ def to_model(cls, kvp):
scope, ALLOWED_SCOPES)
)

# NOTE: For security reasons, encrypted always implies secret=True. See comment
# above for explanation.
if encrypted and not secret:
raise ValueError('encrypted option can only be used in combination with secret '
'option')

model = cls.model(id=kvp_id, name=name, description=description, value=value,
secret=secret, scope=scope,
expire_timestamp=expire_timestamp)

return model

@classmethod
def _verif_key_is_set_up(cls, name):
if not KeyValuePairAPI.crypto_key:
msg = ('Crypto key not found in %s. Unable to encrypt / decrypt value for key %s.' %
(KeyValuePairAPI.crypto_key_path, name))
raise CryptoKeyNotSetupException(msg)


class KeyValuePairSetAPI(KeyValuePairAPI):
"""
Expand Down
Loading