From 40eb950e94183c7d709fa2debc211b76eba4c07e Mon Sep 17 00:00:00 2001 From: Robert Picard Date: Fri, 9 Oct 2015 17:17:05 -0700 Subject: [PATCH] Use MultiFernet for encryption Facilitates key rotation and uses more secure encryption than what sqlalchemy-utils does. Fixes #117 and #119. --- docs/administration/index.rst | 16 +++++-- docs/faq.rst | 4 +- lemur/certificates/models.py | 6 +-- lemur/manage.py | 6 ++- lemur/roles/models.py | 5 +- lemur/tests/conf.py | 2 +- lemur/utils.py | 88 ++++++++++++++++++++++++++++++++--- 7 files changed, 104 insertions(+), 23 deletions(-) diff --git a/docs/administration/index.rst b/docs/administration/index.rst index 3bcceed7..657c8b72 100644 --- a/docs/administration/index.rst +++ b/docs/administration/index.rst @@ -87,17 +87,23 @@ Basic Configuration >>> secret_key = secret_key + ''.join(random.choice(string.digits) for x in range(6)) -.. data:: LEMUR_ENCRYPTION_KEY +.. data:: LEMUR_ENCRYPTION_KEYS :noindex: - The LEMUR_ENCRYPTION_KEY is used to encrypt data at rest within Lemur's database. Without this key Lemur will refuse - to start. + The LEMUR_ENCRYPTION_KEYS is used to encrypt data at rest within Lemur's database. Without a key Lemur will refuse + to start. Multiple keys can be provided to facilitate key rotation. The first key in the list is used for + encryption and all keys are tried for decryption until one works. Each key must be 32 URL safe base-64 encoded bytes. - See `LEMUR_TOKEN_SECRET` for methods of secure secret generation. + Running lemur create_config will securely generate a key for your configuration file. + If you would like to generate your own, we recommend the following method: + + >>> import os + >>> import base64 + >>> base64.urlsafe_b64encode(os.urandom(32)) :: - LEMUR_ENCRYPTION_KEY = 'supersupersecret' + LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s='] Certificate Default Options diff --git a/docs/faq.rst b/docs/faq.rst index a495918e..6ee9c4a9 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -4,8 +4,8 @@ Frequently Asked Questions Common Problems --------------- -In my startup logs I see *'Aborting... Lemur cannot locate db encryption key, is LEMUR_ENCRYPTION_KEY set?'* - You likely have not correctly configured **LEMUR_ENCRYPTION_KEY**. See +In my startup logs I see *'Aborting... Lemur cannot locate db encryption key, is LEMUR_ENCRYPTION_KEYS set?'* + You likely have not correctly configured **LEMUR_ENCRYPTION_KEYS**. See :doc:`administration/index` for more information. diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index 2916b730..7939349a 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -13,9 +13,7 @@ from cryptography.hazmat.backends import default_backend from sqlalchemy.orm import relationship from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean -from sqlalchemy_utils import EncryptedType - -from lemur.utils import get_key +from lemur.utils import Vault from lemur.database import db from lemur.plugins.base import plugins @@ -213,7 +211,7 @@ class Certificate(db.Model): id = Column(Integer, primary_key=True) owner = Column(String(128)) body = Column(Text()) - private_key = Column(EncryptedType(String, get_key)) + private_key = Column(Vault) status = Column(String(128)) deleted = Column(Boolean, index=True) name = Column(String(128)) diff --git a/lemur/manage.py b/lemur/manage.py index a2e4e9e0..bf954509 100755 --- a/lemur/manage.py +++ b/lemur/manage.py @@ -72,7 +72,7 @@ SECRET_KEY = '{flask_secret_key}' # You should consider storing these separately from your config LEMUR_TOKEN_SECRET = '{secret_token}' -LEMUR_ENCRYPTION_KEY = '{encryption_key}' +LEMUR_ENCRYPTION_KEYS = '{encryption_key}' # this is a list of domains as regexes that only admins can issue LEMUR_RESTRICTED_DOMAINS = [] @@ -171,7 +171,9 @@ def generate_settings(): settings file. """ output = CONFIG_TEMPLATE.format( - encryption_key=base64.b64encode(os.urandom(KEY_LENGTH)), + # we use Fernet.generate_key to make sure that the key length is + # compatible with Fernet + encryption_key=Fernet.generate_key(), secret_token=base64.b64encode(os.urandom(KEY_LENGTH)), flask_secret_key=base64.b64encode(os.urandom(KEY_LENGTH)), ) diff --git a/lemur/roles/models.py b/lemur/roles/models.py index 8b32865f..daf482b5 100644 --- a/lemur/roles/models.py +++ b/lemur/roles/models.py @@ -12,9 +12,8 @@ from sqlalchemy.orm import relationship from sqlalchemy import Column, Integer, String, Text, ForeignKey -from sqlalchemy_utils import EncryptedType from lemur.database import db -from lemur.utils import get_key +from lemur.utils import Vault from lemur.models import roles_users @@ -23,7 +22,7 @@ class Role(db.Model): id = Column(Integer, primary_key=True) name = Column(String(128), unique=True) username = Column(String(128)) - password = Column(EncryptedType(String, get_key)) + password = Column(Vault) description = Column(Text) authority_id = Column(Integer, ForeignKey('authorities.id')) user_id = Column(Integer, ForeignKey('users.id')) diff --git a/lemur/tests/conf.py b/lemur/tests/conf.py index 30a0174f..5ff108ed 100644 --- a/lemur/tests/conf.py +++ b/lemur/tests/conf.py @@ -21,7 +21,7 @@ SECRET_KEY = 'I/dVhOZNSMZMqrFJa5tWli6VQccOGudKerq3eWPMSzQNmHHVhMAQfQ==' # You should consider storing these separately from your config LEMUR_TOKEN_SECRET = 'test' -LEMUR_ENCRYPTION_KEY = 'jPd2xwxgVGXONqghHNq7/S761sffYSrT3UAgKwgtMxbqa0gmKYCfag==' +LEMUR_ENCRYPTION_KEYS = 'o61sBLNBSGtAckngtNrfVNd8xy8Hp9LBGDstTbMbqCY=' # this is a list of domains as regexes that only admins can issue LEMUR_RESTRICTED_DOMAINS = [] diff --git a/lemur/utils.py b/lemur/utils.py index 41b054c3..ab8f0f63 100644 --- a/lemur/utils.py +++ b/lemur/utils.py @@ -5,17 +5,93 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ +import six from flask import current_app +from cryptography.fernet import Fernet, MultiFernet +import sqlalchemy.types as types -def get_key(): +def get_keys(): """ - Gets the current encryption key + Gets the encryption keys. + + This supports multiple keys to facilitate key rotation. The first + key in the list is used to encrypt. Decryption is attempted with + each key in succession. :return: """ + + # when running lemur create_config, this code needs to work despite + # the fact that there is not a current_app with a config at that point try: - return current_app.config.get('LEMUR_ENCRYPTION_KEY').strip() - except RuntimeError: - print("No Encryption Key Found") - return '' + keys = current_app.config.get('LEMUR_ENCRYPTION_KEYS') + except: + print("no encryption keys") + return [] + + # this function is expected to return a list of keys, but we want + # to let people just specify a single key + if not isinstance(keys, list): + keys = [keys] + + # make sure there is no accidental whitespace + keys = [key.strip() for key in keys] + + return keys + + +class Vault(types.TypeDecorator): + """ + A custom SQLAlchemy column type that transparently handles encryption. + + This uses the MultiFernet from the cryptography package to faciliate + key rotation. That class handles encryption and signing. + + Fernet uses AES in CBC mode with 128-bit keys and PKCS7 padding. It + uses HMAC-SHA256 for ciphertext authentication. Initialization + vectors are generated using os.urandom(). + """ + + # required by SQLAlchemy. defines the underlying column type + impl = types.Binary + + def process_bind_param(self, value, dialect): + """ + Encrypt values on the way into the database. + + MultiFernet.encrypt uses the first key in the list. + """ + + # we assume that the user's keys are already Fernet keys (32 byte + # keys that have been base64 encoded). + self.keys = [Fernet(key) for key in get_keys()] + + # we only support strings and they should be of type bytes for Fernet + if not isinstance(value, six.string_types): + return None + + value = bytes(value) + + return MultiFernet(self.keys).encrypt(value) + + def process_result_value(self, value, dialect): + """ + Decrypt values on the way out of the database. + + MultiFernet tries each key until one works. + """ + + # we assume that the user's keys are already Fernet keys (32 byte + # keys that have been base64 encoded). + self.keys = [Fernet(key) for key in get_keys()] + + # if the value is not a string we aren't going to try to decrypt + # it. this is for the case where the column is null + if not isinstance(value, six.string_types): + return None + + # TODO this may raise an InvalidToken exception in certain + # cases. Should we handle that? + # https://cryptography.io/en/latest/fernet/#cryptography.fernet.Fernet.decrypt + return MultiFernet(self.keys).decrypt(value)