diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1e5447..dcae3a4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: pip install --upgrade poetry - name: Download Asherah binaries run: | - ./download-libasherah.sh + scripts/download-libasherah.sh - name: Package and publish with Poetry run: | poetry config pypi-token.pypi $PYPI_TOKEN diff --git a/.gitignore b/.gitignore index e8dfd51..6ad6f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,8 @@ dmypy.json # Pyre type checker .pyre/ + +# Editors +.idea/ +.vscode/ +*.swp diff --git a/README.md b/README.md index 354ab71..b6eaec6 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ Example code: from asherah import Asherah, AsherahConfig config = AsherahConfig( - kms_type='static', + kms='static', metastore='memory', service_name='TestService', product_id='TestProduct', verbose=True, - session_cache=True + enable_session_caching=True ) crypt = Asherah() crypt.setup(config) diff --git a/asherah/asherah.py b/asherah/asherah.py index e57767a..b66d2bb 100644 --- a/asherah/asherah.py +++ b/asherah/asherah.py @@ -1,9 +1,10 @@ """Main Asherah class, for encrypting and decrypting of data""" -# pylint: disable=line-too-long, too-many-locals +# pylint: disable=line-too-long + +from __future__ import annotations import json import os -from datetime import datetime, timezone from typing import ByteString, Union from cobhan import Cobhan @@ -14,6 +15,7 @@ class Asherah: """The main class for providing encryption and decryption functionality""" + JSON_OVERHEAD = 256 KEY_SIZE = 64 def __init__(self): @@ -22,9 +24,12 @@ def __init__(self): os.path.join(os.path.dirname(__file__), "libasherah"), "libasherah", """ + void Shutdown(); int32_t SetupJson(void* configJson); int32_t Decrypt(void* partitionIdPtr, void* encryptedDataPtr, void* encryptedKeyPtr, int64_t created, void* parentKeyIdPtr, int64_t parentKeyCreated, void* outputDecryptedDataPtr); int32_t Encrypt(void* partitionIdPtr, void* dataPtr, void* outputEncryptedDataPtr, void* outputEncryptedKeyPtr, void* outputCreatedPtr, void* outputParentKeyIdPtr, void* outputParentKeyCreatedPtr); + int32_t EncryptToJson(void* partitionIdPtr, void* dataPtr, void* jsonPtr); + int32_t DecryptFromJson(void* partitionIdPtr, void* jsonPtr, void* dataPtr); """, ) @@ -38,6 +43,10 @@ def setup(self, config: types.AsherahConfig) -> None: f"Setup failed with error number {result}" ) + def shutdown(self): + """Shut down and clean up the Asherah instance""" + self.__libasherah.Shutdown() + def encrypt(self, partition_id: str, data: Union[ByteString, str]): """Encrypt a chunk of data""" if isinstance(data, str): @@ -46,71 +55,27 @@ def encrypt(self, partition_id: str, data: Union[ByteString, str]): partition_id_buf = self.__cobhan.str_to_buf(partition_id) data_buf = self.__cobhan.bytearray_to_buf(data) # Outputs - encrypted_data_buf = self.__cobhan.allocate_buf(len(data) + self.KEY_SIZE) - encrypted_key_buf = self.__cobhan.allocate_buf(self.KEY_SIZE) - created_buf = self.__cobhan.int_to_buf(0) - parent_key_id_buf = self.__cobhan.allocate_buf(self.KEY_SIZE) - parent_key_created_buf = self.__cobhan.int_to_buf(0) + json_buf = self.__cobhan.allocate_buf(len(data_buf) + self.JSON_OVERHEAD) - result = self.__libasherah.Encrypt( - partition_id_buf, - data_buf, - encrypted_data_buf, - encrypted_key_buf, - created_buf, - parent_key_id_buf, - parent_key_created_buf, - ) + result = self.__libasherah.EncryptToJson(partition_id_buf, data_buf, json_buf) if result < 0: raise exceptions.AsherahException( f"Encrypt failed with error number {result}" ) - data_row_record = types.DataRowRecord( - data=self.__cobhan.buf_to_bytearray(encrypted_data_buf), - key=types.EnvelopeKeyRecord( - encrypted_key=self.__cobhan.buf_to_bytearray(encrypted_key_buf), - created=datetime.fromtimestamp( - self.__cobhan.buf_to_int(created_buf), tz=timezone.utc - ), - parent_key_meta=types.KeyMeta( - id=self.__cobhan.buf_to_str(parent_key_id_buf), - created=datetime.fromtimestamp( - self.__cobhan.buf_to_int(parent_key_created_buf), - tz=timezone.utc, - ), - ), - ), - ) + return self.__cobhan.buf_to_str(json_buf) - return data_row_record - - def decrypt( - self, partition_id: str, data_row_record: types.DataRowRecord - ) -> bytearray: + def decrypt(self, partition_id: str, data_row_record: str) -> bytearray: """Decrypt data that was previously encrypted by Asherah""" # Inputs partition_id_buf = self.__cobhan.str_to_buf(partition_id) - encrypted_data_buf = self.__cobhan.bytearray_to_buf(data_row_record.data) - encrypted_key_buf = self.__cobhan.bytearray_to_buf( - data_row_record.key.encrypted_key - ) - created = int(data_row_record.key.created.timestamp()) - parent_key_id_buf = self.__cobhan.str_to_buf( - data_row_record.key.parent_key_meta.id - ) - parent_key_created = int( - data_row_record.key.parent_key_meta.created.timestamp() - ) + json_buf = self.__cobhan.str_to_buf(data_row_record) + # Output - data_buf = self.__cobhan.allocate_buf(len(encrypted_data_buf) + self.KEY_SIZE) + data_buf = self.__cobhan.allocate_buf(len(json_buf)) - result = self.__libasherah.Decrypt( + result = self.__libasherah.DecryptFromJson( partition_id_buf, - encrypted_data_buf, - encrypted_key_buf, - created, - parent_key_id_buf, - parent_key_created, + json_buf, data_buf, ) diff --git a/asherah/scripts/download-libasherah.sh b/asherah/scripts/download-libasherah.sh new file mode 100755 index 0000000..8cefca9 --- /dev/null +++ b/asherah/scripts/download-libasherah.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +rm -rf asherah/libasherah/ + +wget --content-disposition --directory-prefix asherah/libasherah/ \ + https://github.com/godaddy/asherah-cobhan/releases/download/v0.3.1/libasherah-arm64.dylib \ + https://github.com/godaddy/asherah-cobhan/releases/download/v0.3.1/libasherah-arm64.so \ + https://github.com/godaddy/asherah-cobhan/releases/download/v0.3.1/libasherah-x64.dylib \ + https://github.com/godaddy/asherah-cobhan/releases/download/v0.3.1/libasherah-x64.so \ + || exit 1 diff --git a/asherah/types.py b/asherah/types.py index 0ed075f..13ef828 100644 --- a/asherah/types.py +++ b/asherah/types.py @@ -1,60 +1,103 @@ """Type definitions for the Asherah library""" -# pylint: disable=too-many-instance-attributes,invalid-name +# pylint: disable=too-many-instance-attributes + +from __future__ import annotations from dataclasses import asdict, dataclass -from datetime import datetime -from typing import ByteString, Optional +from typing import Dict, Optional + +from enum import Enum + + +class KMSType(Enum): + """Supported types of KMS services""" + + AWS = "aws" + STATIC = "static" + + +class MetastoreType(Enum): + """Supported types of metastores""" + + RDBMS = "rdbms" + DYNAMODB = "dynamodb" + MEMORY = "memory" + + +class ReadConsistencyType(Enum): + """Supported read consistency types""" + + EVENTUAL = "eventual" + GLOBAL = "global" + SESSION = "session" @dataclass class AsherahConfig: - """Configuration options for Asherah setup""" + """Configuration options for Asherah setup + + :param kms: Configures the master key management service (aws or static) + :param metastore: Determines the type of metastore to use for persisting + types + :param service_name: The name of the service + :param product_id: The name of the product that owns this service + :param connection_string: The database connection string (Required if + metastore is rdbms) + :param dynamo_db_endpoint: An optional endpoint URL (hostname only or fully + qualified URI) (only supported by metastore = dynamodb) + :param dynamo_db_region: The AWS region for DynamoDB requests (defaults to + globally configured region) (only supported by metastore = dynamodb) + :param dynamo_db_table_name: The table name for DynamoDB (only supported by + metastore = dynamodb) + :param enable_region_suffix: Configure the metastore to use regional + suffixes (only supported by metastore = dynamodb) + :param preferred_region: The preferred AWS region (required if kms is aws) + :param region_map: Dictionary of REGION: ARN (required if kms is aws) + :param verbose: Enable verbose logging output + :param enable_session_caching: Enable shared session caching + :param expire_after: The amount of time a key is considered valid + :param check_interval: The amount of time before cached keys are considered + stale + :param replica_read_consistency: Required for Aurora sessions using write + forwarding (eventual, global, session) + :param session_cache_max_size: Define the maximum number of sessions to + cache (default 1000) + :param session_cache_max_duration: The amount of time a session will remain + cached (default 2h) + """ - kms_type: str - metastore: str + kms: KMSType + metastore: MetastoreType service_name: str product_id: str - rdbms_connection_string: Optional[str] = None + connection_string: Optional[str] = None dynamo_db_endpoint: Optional[str] = None dynamo_db_region: Optional[str] = None dynamo_db_table_name: Optional[str] = None enable_region_suffix: bool = False preferred_region: Optional[str] = None - region_map: Optional[str] = None + region_map: Optional[Dict[str, str]] = None verbose: bool = False - session_cache: bool = False - debug_output: bool = False + enable_session_caching: bool = False + expire_after: Optional[int] = None + check_interval: Optional[int] = None + replica_read_consistency: Optional[ReadConsistencyType] = None + session_cache_max_size: Optional[int] = None + session_cache_duration: Optional[int] = None def to_json(self): - def camel_case(key): + """Produce a JSON dictionary in a form expected by Asherah""" + + def translate_key(key): """Translate snake_case into camelCase.""" parts = key.split("_") - parts = [parts[0]] + [part.capitalize() for part in parts[1:]] + parts = [ + part.capitalize() + .replace("Db", "DB") + .replace("Id", "ID") + .replace("Kms", "KMS") + for part in parts + ] return "".join(parts) - return {camel_case(key): val for key, val in asdict(self).items()} - - -@dataclass -class KeyMeta: - """Metadata about an encryption key""" - - id: str - created: datetime - - -@dataclass -class EnvelopeKeyRecord: - """Information about an encryption envelope""" - - encrypted_key: ByteString - created: datetime - parent_key_meta: KeyMeta - - -@dataclass -class DataRowRecord: - """Encrypted data and its related information""" - - data: ByteString - key: EnvelopeKeyRecord + return {translate_key(key): val for key, val in asdict(self).items()} diff --git a/benchmark.py b/benchmark.py index 0595eb5..011912d 100644 --- a/benchmark.py +++ b/benchmark.py @@ -4,11 +4,11 @@ from asherah import Asherah, AsherahConfig config = AsherahConfig( - kms_type="static", + kms="static", metastore="memory", service_name="TestService", product_id="TestProduct", - session_cache=True, + enable_session_caching=True, ) crypt = Asherah() crypt.setup(config) diff --git a/download-libasherah.sh b/download-libasherah.sh deleted file mode 100755 index 5425c65..0000000 --- a/download-libasherah.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -rm -rf asherah/libasherah/ - -wget --content-disposition --directory-prefix asherah/libasherah/ \ - https://github.com/godaddy/asherah-cobhan/releases/download/current/libasherah-arm64.dylib \ - https://github.com/godaddy/asherah-cobhan/releases/download/current/libasherah-arm64.so \ - https://github.com/godaddy/asherah-cobhan/releases/download/current/libasherah-x64.dylib \ - https://github.com/godaddy/asherah-cobhan/releases/download/current/libasherah-x64.so \ - || exit 1 diff --git a/poetry.lock b/poetry.lock index 586c475..cac55ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -82,7 +82,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "cobhan" -version = "0.3.0" +version = "0.4.2" description = "Cobhan FFI" category = "main" optional = false @@ -135,7 +135,7 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "importlib-metadata" -version = "4.11.2" +version = "4.11.3" description = "Read metadata from Python packages" category = "dev" optional = false @@ -306,11 +306,11 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.0.1" +version = "7.1.0" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} @@ -427,7 +427,7 @@ python-versions = ">=3.6" [[package]] name = "virtualenv" -version = "20.13.2" +version = "20.13.3" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -467,7 +467,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "09a13bc9f002f134e162b08befc5acf7a86bbc30fda72973f10d8741ba585559" +content-hash = "a73a267f2aab6826630485161423d1ef6de2eca12581b3a510f05140d2c753c4" [metadata.files] astroid = [ @@ -564,8 +564,8 @@ click = [ {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, ] cobhan = [ - {file = "cobhan-0.3.0-py3-none-any.whl", hash = "sha256:63b044fa924f47e32ea2349c96e06d63034622b238875e0d3a25433a05c4bed8"}, - {file = "cobhan-0.3.0.tar.gz", hash = "sha256:8745ccb75071d263c09d5632f5995995682d5c14e6e12e5d811bef297b379f0e"}, + {file = "cobhan-0.4.2-py3-none-any.whl", hash = "sha256:3908fe3fd7b349b71b71ca70f293eb21a2b587383a20982618d47a3f0fa325c4"}, + {file = "cobhan-0.4.2.tar.gz", hash = "sha256:1814dd184ed01def226a380bafaaf44d51abc4963c532ae28c8b97abf4029824"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -623,8 +623,8 @@ filelock = [ {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.11.2-py3-none-any.whl", hash = "sha256:d16e8c1deb60de41b8e8ed21c1a7b947b0bc62fab7e1d470bcdf331cea2e6735"}, - {file = "importlib_metadata-4.11.2.tar.gz", hash = "sha256:b36ffa925fe3139b2f6ff11d6925ffd4fa7bc47870165e3ac260ac7b4f91e6ac"}, + {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, + {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -736,8 +736,8 @@ pyparsing = [ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, - {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, + {file = "pytest-7.1.0-py3-none-any.whl", hash = "sha256:b555252a95bbb2a37a97b5ac2eb050c436f7989993565f5e0c9128fcaacadd0e"}, + {file = "pytest-7.1.0.tar.gz", hash = "sha256:f1089d218cfcc63a212c42896f1b7fbf096874d045e1988186861a1a87d27b47"}, ] pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, @@ -796,8 +796,8 @@ typing-extensions = [ {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] virtualenv = [ - {file = "virtualenv-20.13.2-py2.py3-none-any.whl", hash = "sha256:e7b34c9474e6476ee208c43a4d9ac1510b041c68347eabfe9a9ea0c86aa0a46b"}, - {file = "virtualenv-20.13.2.tar.gz", hash = "sha256:01f5f80744d24a3743ce61858123488e91cb2dd1d3bdf92adaf1bba39ffdedf0"}, + {file = "virtualenv-20.13.3-py2.py3-none-any.whl", hash = "sha256:dd448d1ded9f14d1a4bfa6bfc0c5b96ae3be3f2d6c6c159b23ddcfd701baa021"}, + {file = "virtualenv-20.13.3.tar.gz", hash = "sha256:e9dd1a1359d70137559034c0f5433b34caf504af2dc756367be86a5a32967134"}, ] wrapt = [ {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, diff --git a/pyproject.toml b/pyproject.toml index ecd6c7c..6d8db3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -cobhan = "^0.3.0" +cobhan = "^0.4.2" [tool.poetry.dev-dependencies] black = "^22.1.0" @@ -48,5 +48,26 @@ pytest = "^7.0.1" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" +[tool.coverage.run] +branch = true +source = ["asherah"] + +[tool.coverage.report] +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", # Don't complain about missing debug-only code: + "def __repr__", + "if self.debug", # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", # Don't complain about mypy-specific code + "if TYPE_CHECKING:", +] +ignore_errors = true + [tool.mypy] ignore_missing_imports = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report term --cov-report term-missing --cov-report xml --durations=0" diff --git a/tests/test_asherah.py b/tests/test_asherah.py index 911f76b..78321c2 100644 --- a/tests/test_asherah.py +++ b/tests/test_asherah.py @@ -2,7 +2,36 @@ from unittest import TestCase +from asherah import Asherah, AsherahConfig + class AsherahTest(TestCase): - def test_fake(self): - self.assertEqual(True, True) + @classmethod + def setUpClass(cls) -> None: + cls.config = AsherahConfig( + kms="static", + metastore="memory", + service_name="TestService", + product_id="TestProduct", + verbose=True, + enable_session_caching=True, + ) + cls.asherah = Asherah() + cls.asherah.setup(cls.config) + return super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + cls.asherah.shutdown() + return super().tearDownClass() + + def test_input_string_is_not_in_encrypted_data(self): + data = "mysecretdata" + encrypted = self.asherah.encrypt("partition", data) + self.assertFalse(data in encrypted) + + def test_decrypted_data_equals_original_data(self): + data = b"mysecretdata" + encrypted = self.asherah.encrypt("partition", data) + decrypted = self.asherah.decrypt("partition", encrypted) + self.assertEqual(decrypted, data)