diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7ec1e1..293ea0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - main + - candidate-* pull_request: types: [opened, synchronize, reopened] branches: - main + - candidate-* workflow_dispatch: permissions: diff --git a/examples/5_hello_world_rsa/README.md b/examples/5_hello_world_rsa/README.md new file mode 100644 index 0000000..d140269 --- /dev/null +++ b/examples/5_hello_world_rsa/README.md @@ -0,0 +1,68 @@ +# Hello World with RSA Encryption Example + +This example demonstrates how to use RSA encryption with INTERSECT services. + +## Overview + +RSA encryption provides end-to-end encryption for messages between clients and services: +- Clients encrypt requests using the service's public key +- Services encrypt responses using the client's public key +- Each party decrypts messages using their own private key + +## Files + +- `hello_service.py` - Service implementation with RSA encryption enabled +- `hello_client.py` - Client implementation that sends RSA encrypted messages +- `hello_service_schema.json` - Service schema defining available operations + +## Key Features + +1. **Automatic RSA Key Generation**: + - Both service and client automatically generate RSA key pairs internally + - No manual key management required + - Service automatically handles public key requests from clients + +2. **Transparent Encryption**: + - Simply set `encryption_scheme='RSA'` when sending messages + - INTERSECT SDK handles all encryption/decryption automatically + - Public key exchange happens automatically in the background + +## Running the Example + +1. Start the service: + ```bash + python -m examples.5_hello_world_rsa.hello_service + ``` + +2. In another terminal, run the client: + ```bash + python -m examples.5_hello_world_rsa.hello_client + ``` + +## Expected Output + +Client output: +``` +Hello, hello_client! +``` + +## How It Works + +1. Client starts and generates its RSA key pair +2. Client sends encrypted message to service (encryption_scheme='RSA') +3. Client first fetches service's public key via `intersect_sdk.get_public_key` +4. Client encrypts the request using service's public key +5. Service receives encrypted request and decrypts it using its private key +6. Service processes the request and generates a response +7. Service fetches client's public key (if not cached) +8. Service encrypts response using client's public key +9. Client receives encrypted response and decrypts it using its private key +10. Client prints the decrypted response + +## Security Notes + +- Each service and client automatically generates its own unique RSA key pair on startup +- Private keys are managed internally by the SDK and never transmitted +- Public keys are exchanged automatically via the INTERSECT messaging system +- RSA encryption uses 2048-bit keys for strong security +- No manual key management is required - the SDK handles everything automatically diff --git a/examples/5_hello_world_rsa/__init__.py b/examples/5_hello_world_rsa/__init__.py new file mode 100644 index 0000000..29ef2b6 --- /dev/null +++ b/examples/5_hello_world_rsa/__init__.py @@ -0,0 +1 @@ +"""Init file for hello world RSA encryption example.""" diff --git a/examples/5_hello_world_rsa/hello_client.py b/examples/5_hello_world_rsa/hello_client.py new file mode 100644 index 0000000..c149968 --- /dev/null +++ b/examples/5_hello_world_rsa/hello_client.py @@ -0,0 +1,80 @@ +""" +Hello World Client with RSA Encryption + +This example demonstrates how to use RSA encryption when sending messages to INTERSECT services. +The client will encrypt requests using the service's public key and decrypt responses using its own private key. +""" + +import time + +from intersect_sdk import ( + HierarchyConfig, + IntersectClient, + IntersectServiceConfig, +) +from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams + +""" +step one: create IntersectServiceConfig + +Note: The client automatically generates an RSA key pair internally. +You don't need to manually create or manage encryption keys. +""" +config = IntersectServiceConfig( + brokers=[ + { + 'username': 'intersect_username', + 'password': 'intersect_password', + 'port': 1883, + 'protocol': 'mqtt5.0', + } + ], + hierarchy=HierarchyConfig( + organization='hello-organization', + facility='hello-facility', + system='hello-system', + subsystem='hello-subsystem-client', + service='hello-client', + ), +) + +""" +step two: set up callback and then instantiate client +""" + + +def user_callback(source: str, operation: str, has_error: bool, payload: dict) -> None: + if has_error: + print(f'Error from {source}: {payload}') + else: + print(payload) + + +intersect_client = IntersectClient( + config.hierarchy, + config=config, +) + +""" +step three: start up client with user callback +""" +intersect_client.startup(user_callback=user_callback) + +""" +step four: send message with RSA encryption +Note the encryption_scheme='RSA' parameter +""" +message_params = IntersectDirectMessageParams( + destination='hello-organization.hello-facility.hello-system.hello-subsystem.hello-service', + operation='HelloExample.say_hello_to_name', + payload='hello_client', + encryption_scheme='RSA', # Enable RSA encryption +) + +intersect_client.send_message(message_params) +time.sleep(3) + +""" +step five: shut down client +""" +intersect_client.shutdown() diff --git a/examples/5_hello_world_rsa/hello_service.py b/examples/5_hello_world_rsa/hello_service.py new file mode 100644 index 0000000..730c3c8 --- /dev/null +++ b/examples/5_hello_world_rsa/hello_service.py @@ -0,0 +1,86 @@ +""" +Hello World Service with RSA Encryption + +This example demonstrates how to use RSA encryption with INTERSECT services. +The service will encrypt responses to clients using the client's public key. +""" + +import logging + +from intersect_sdk import ( + HierarchyConfig, + IntersectBaseCapabilityImplementation, + IntersectService, + IntersectServiceConfig, + default_intersect_lifecycle_loop, + intersect_message, + intersect_status, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class HelloServiceCapabilityImplementation(IntersectBaseCapabilityImplementation): + """Capability implementation with RSA encryption support.""" + + intersect_sdk_capability_name = 'HelloExample' + + @intersect_status() + def status(self) -> str: + """Basic status function which returns a hard-coded string.""" + return 'Up' + + @intersect_message() + def say_hello_to_name(self, name: str) -> str: + """Takes in a string parameter and says 'Hello' to the parameter!""" + return f'Hello, {name}!' + + +if __name__ == '__main__': + """ + Create a service with RSA encryption enabled. + + The service automatically generates an RSA key pair internally. + When clients request RSA encryption, the service will: + 1. Fetch the client's public key + 2. Encrypt the response using the client's public key + 3. The client decrypts using its private key + """ + + from_config_file = { + 'brokers': [ + { + 'username': 'intersect_username', + 'password': 'intersect_password', + 'port': 1883, + 'protocol': 'mqtt5.0', + } + ], + 'hierarchy': { + 'organization': 'hello-organization', + 'facility': 'hello-facility', + 'system': 'hello-system', + 'subsystem': 'hello-subsystem', + 'service': 'hello-service', + }, + } + + config = IntersectServiceConfig(**from_config_file) + + """ + step two: create instances of your capabilities, which you will inject into the service + """ + capabilities = [HelloServiceCapabilityImplementation()] + + """ + step three: create the service using your capability and configuration + """ + intersect_service = IntersectService(capabilities, config) + + """ + step four: utilize the default_intersect_lifecycle_loop function to manage startup, + the user lifecycle loop, and shutdown automatically. You must pass it the service + and a function containing your optional lifecycle code to execute. + """ + default_intersect_lifecycle_loop(intersect_service) diff --git a/examples/5_hello_world_rsa/hello_service_schema.json b/examples/5_hello_world_rsa/hello_service_schema.json new file mode 100644 index 0000000..2319199 --- /dev/null +++ b/examples/5_hello_world_rsa/hello_service_schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/INTERSECT-SDK/python-sdk/main/src/intersect_sdk/_internal/schema.schema.json", + "intersect_sdk_version": "0.8.0", + "name": "hello-organization.hello-facility.hello-system.hello-subsystem.hello-service", + "capabilities": [ + { + "name": "HelloExample", + "functions": [ + { + "name": "say_hello_to_name", + "parameters": { + "type": "string" + }, + "returns": { + "type": "string" + } + } + ], + "encryption_schemes": [ + "NONE", + "RSA" + ] + } + ] +} diff --git a/pdm.lock b/pdm.lock index 6bd94e5..7b51f3d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "docs", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:6092f52e5a133eae863e91b78ffeb23090b7a9a7dc49a7eb6350ba3dc52cf132" +content_hash = "sha256:69558bff1dba56b3e5d8f072d7e4b0e67daebfa4c10dd770eadfafcc904f7efe" [[metadata.targets]] requires_python = "~=3.10" @@ -143,61 +143,86 @@ files = [ [[package]] name = "cffi" -version = "1.17.0" -requires_python = ">=3.8" +version = "2.0.0" +requires_python = ">=3.9" summary = "Foreign Function Interface for Python calling C code." groups = ["default"] dependencies = [ - "pycparser", -] -files = [ - {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, - {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, - {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, - {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, - {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, - {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, - {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, - {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, - {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, - {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, - {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, + "pycparser; implementation_name != \"PyPy\"", +] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [[package]] @@ -417,6 +442,69 @@ files = [ {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] +[[package]] +name = "cryptography" +version = "46.0.4" +requires_python = "!=3.9.0,!=3.9.1,>=3.8" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +groups = ["default"] +dependencies = [ + "cffi>=1.14; python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"", + "cffi>=2.0.0; python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", + "typing-extensions>=4.13.2; python_full_version < \"3.11\"", +] +files = [ + {file = "cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616"}, + {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0"}, + {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0"}, + {file = "cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5"}, + {file = "cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b"}, + {file = "cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f"}, + {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82"}, + {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c"}, + {file = "cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061"}, + {file = "cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7"}, + {file = "cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019"}, + {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4"}, + {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b"}, + {file = "cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc"}, + {file = "cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3"}, + {file = "cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59"}, +] + [[package]] name = "distlib" version = "0.3.8" @@ -849,6 +937,7 @@ version = "2.22" requires_python = ">=3.8" summary = "C parser in Python" groups = ["default"] +marker = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -1414,13 +1503,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.12.2" -requires_python = ">=3.8" -summary = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" groups = ["default", "lint"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index b6abadf..77b2e2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "jsonschema[format-nongpl]>=4.21.1", # extras necessary for enforcing formats #"brotli>=1.1.0", # TODO - add this dependency when we add compression "psutil>=7.0.0", + "cryptography>=46.0.4", ] [project.urls] diff --git a/src/intersect_sdk/__init__.py b/src/intersect_sdk/__init__.py index f512d93..2fd9019 100644 --- a/src/intersect_sdk/__init__.py +++ b/src/intersect_sdk/__init__.py @@ -29,7 +29,7 @@ DataStoreConfigMap, HierarchyConfig, ) - from .core_definitions import IntersectDataHandler, IntersectMimeType + from .core_definitions import IntersectDataHandler, IntersectEncryptionScheme, IntersectMimeType from .exceptions import IntersectCapabilityError from .schema import get_schema_from_capability_implementations from .service import IntersectService @@ -67,6 +67,7 @@ 'IntersectClientConfig', 'IntersectDataHandler', 'IntersectDirectMessageParams', + 'IntersectEncryptionScheme', 'IntersectEventDefinition', 'IntersectEventMessageParams', 'IntersectMimeType', @@ -101,6 +102,7 @@ 'IntersectClientConfig': '.config.client', 'IntersectDataHandler': '.core_definitions', 'IntersectDirectMessageParams': '.shared_callback_definitions', + 'IntersectEncryptionScheme': '.core_definitions', 'IntersectEventDefinition': '.service_definitions', 'IntersectEventMessageParams': '.shared_callback_definitions', 'IntersectMimeType': '.core_definitions', diff --git a/src/intersect_sdk/_internal/constants.py b/src/intersect_sdk/_internal/constants.py index fc3557a..dc5973f 100644 --- a/src/intersect_sdk/_internal/constants.py +++ b/src/intersect_sdk/_internal/constants.py @@ -6,3 +6,4 @@ RESPONSE_DATA = '__response_data_transfer_handler__' STRICT_VALIDATION = '__strict_validation__' SHUTDOWN_KEYS = '__ignore_message__' +ENCRYPTION_SCHEMES = '__intersect_encryption_schemes__' diff --git a/src/intersect_sdk/_internal/data_plane/data_plane_manager.py b/src/intersect_sdk/_internal/data_plane/data_plane_manager.py index 98e55b9..4d80fc0 100644 --- a/src/intersect_sdk/_internal/data_plane/data_plane_manager.py +++ b/src/intersect_sdk/_internal/data_plane/data_plane_manager.py @@ -8,7 +8,13 @@ from ...core_definitions import IntersectDataHandler, IntersectMimeType from ..exceptions import IntersectError from ..logger import logger -from .minio_utils import MinioPayload, create_minio_store, get_minio_object, send_minio_object +from .minio_utils import ( + MinioPayload, + create_minio_store, + delete_minio_object, + get_minio_object, + send_minio_object, +) if TYPE_CHECKING: from ...config.shared import DataStoreConfigMap, HierarchyConfig @@ -111,3 +117,35 @@ def outgoing_message_data_handler( f'No support implemented for code {data_handler}, please upgrade your intersect-sdk version.' ) raise IntersectError + + def remove_remote_data( + self, message: bytes, request_data_handler: IntersectDataHandler + ) -> None: + """Removes data from the request data provider. + + This does not raise an exception if unable to remove the data, just logs the problem. + In general, this should only be called if you can verify an issue in the headers + + Params: + message: the message sent externally to this location + Returns: + the actual data we want to submit to the user function + """ + if request_data_handler == IntersectDataHandler.MINIO: + # TODO - we may want to send additional provider information in the payload + try: + payload: MinioPayload = MINIO_ADAPTER.validate_json(message) + except ValidationError as e: + logger.warning('remove_remote - invalid params', e) + return + provider = None + for store in self._minio_providers: + if store._base_url._url.geturl() == payload['minio_url']: # noqa: SLF001 (only way to get URL from MINIO API) + provider = store + break + if not provider: + logger.error( + f"You did not configure listening to MINIO instance '{payload['minio_url']}'. You must fix this to handle this data." + ) + return + delete_minio_object(provider, payload) diff --git a/src/intersect_sdk/_internal/data_plane/minio_utils.py b/src/intersect_sdk/_internal/data_plane/minio_utils.py index 6f94137..fa0831d 100644 --- a/src/intersect_sdk/_internal/data_plane/minio_utils.py +++ b/src/intersect_sdk/_internal/data_plane/minio_utils.py @@ -152,3 +152,27 @@ def get_minio_object(provider: Minio, payload: MinioPayload) -> bytes: raise IntersectError from e else: return response.data + + +def delete_minio_object(provider: Minio, payload: MinioPayload) -> None: + """Delete an object from the bucket, without returning it. + + Params: + provider: a pre-cached MinIO provider from the data provider store + payload: the payload from the message (at this point, the minio_url should exist) + + Raises: + IntersectException - if any non-fatal MinIO error is caught + """ + try: + provider.remove_object( + bucket_name=payload['minio_bucket'], object_name=payload['minio_object_id'] + ) + except MaxRetryError as e: + logger.warning( + f'Non-fatal MinIO error when retrieving object, the server may be under stress but you should double-check your configuration. Details: \n{e}' + ) + except MinioException as e: + logger.error( + f'Important MinIO error when retrieving object, this usually indicates a problem with your configuration. Details: \n{e}' + ) diff --git a/src/intersect_sdk/_internal/encryption/__init__.py b/src/intersect_sdk/_internal/encryption/__init__.py new file mode 100644 index 0000000..a17b235 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/__init__.py @@ -0,0 +1,10 @@ +from .aes_cypher import AESCipher +from .client_encryption import intersect_payload_encrypt +from .service_decryption import intersect_payload_decrypt + + +__all__ = ( + 'AESCipher', + 'intersect_payload_encrypt', + 'intersect_payload_decrypt', +) \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/aes_cypher.py b/src/intersect_sdk/_internal/encryption/aes_cypher.py new file mode 100644 index 0000000..0d2bb13 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/aes_cypher.py @@ -0,0 +1,40 @@ +""" +AESCipher class for intersect_sdk._internal.encryption.aes_cypher +""" +import base64 +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.padding import PKCS7 +from cryptography.hazmat.backends import default_backend + + +class AESCipher: + def __init__(self, key: bytes, initialization_vector: bytes): + if len(key) * 8 != 256: + raise ValueError("Invalid key size (must be 256 bit)") + self._key: bytes = key + # No need to encrypt initialization vectors + # https://cryptography.io/en/latest/hazmat/primitives/symmetric-encryption/#cryptography.hazmat.primitives.ciphers.modes.CBC + self._initial_vector: bytes = initialization_vector + self._cipher: Cipher = Cipher( + algorithms.AES(self._key), + mode=modes.CBC(self._initial_vector), + backend=default_backend(), + ) + + def encrypt(self, plaintext: bytes) -> bytes: + encryptor = self._cipher.encryptor() + bytes_to_bits = 8 + padder = PKCS7(len(self._initial_vector) * bytes_to_bits).padder() + padded_data = padder.update(plaintext) + padder.finalize() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + encodedciphertext = base64.b64encode(ciphertext) + return encodedciphertext + + def decrypt(self, ciphertext: bytes) -> bytes: + decryptor = self._cipher.decryptor() + decodedciphertext = base64.b64decode(ciphertext) + padded_data = decryptor.update(decodedciphertext) + decryptor.finalize() + bytes_to_bits = 8 + unpadder = PKCS7(len(self._initial_vector) * bytes_to_bits).unpadder() + plaintext = unpadder.update(padded_data) + unpadder.finalize() + return plaintext \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/client_encryption.py b/src/intersect_sdk/_internal/encryption/client_encryption.py new file mode 100644 index 0000000..131f1f2 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/client_encryption.py @@ -0,0 +1,57 @@ +""" +Client encryption function for intersect_sdk._internal.encryption.client_encryption +RSA asymmetric encryption and AES symmetric encryption +""" +# RSA asymmetric encryption and AES symmetric encryption +import base64 +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.backends import default_backend +import os + + +from .aes_cypher import AESCipher +from .models import IntersectEncryptedPayload, IntersectEncryptionPublicKey +from ..logger import logger + +def intersect_payload_encrypt( + key_payload: IntersectEncryptionPublicKey, + unencrypted_model: str, +) -> IntersectEncryptedPayload: + # Decode and deserialize the public key for asymmetric encrypt + public_rsa_key = serialization.load_pem_public_key( + key_payload.public_key.encode(), + backend=default_backend(), + ) + + # Setup AES + aes_key: bytes = os.urandom(32) + aes_initialization_vector: bytes = os.urandom(16) + cipher = AESCipher( + key=aes_key, + initialization_vector=aes_initialization_vector, + ) + + # Encrypt using AES + logger.info("AES encrypting payload...") + encrypted_data = cipher.encrypt(unencrypted_model.encode()) + logger.info("AES encrypted the payload!") + + # Asymmetric RSA encrypts AES symmetric key using RSA public key + logger.info("RSA encrypting AES key...") + encrypted_aes_key: bytes = public_rsa_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + logger.info("RSA encrypted AES key!") + logger.info("Done encrypting!") + + return IntersectEncryptedPayload( + key=base64.b64encode(encrypted_aes_key).decode(), + initial_vector=base64.b64encode(aes_initialization_vector).decode(), + data=base64.b64encode(encrypted_data).decode(), + ) diff --git a/src/intersect_sdk/_internal/encryption/models/__init__.py b/src/intersect_sdk/_internal/encryption/models/__init__.py new file mode 100644 index 0000000..3c14c45 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/models/__init__.py @@ -0,0 +1,9 @@ +from .decrypted_payload import IntersectDecryptedPayload +from .encrypted_payload import IntersectEncryptedPayload +from .public_key import IntersectEncryptionPublicKey + +__all__ = ( + "IntersectDecryptedPayload", + "IntersectEncryptedPayload", + "IntersectEncryptionPublicKey", +) \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/models/decrypted_payload.py b/src/intersect_sdk/_internal/encryption/models/decrypted_payload.py new file mode 100644 index 0000000..1df7134 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/models/decrypted_payload.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class IntersectDecryptedPayload(BaseModel): + model: BaseModel + aes_key: bytes + aes_initialization_vector: bytes \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/models/encrypted_payload.py b/src/intersect_sdk/_internal/encryption/models/encrypted_payload.py new file mode 100644 index 0000000..185898c --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/models/encrypted_payload.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class IntersectEncryptedPayload(BaseModel): + key: str + initial_vector: str + data: str \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/models/public_key.py b/src/intersect_sdk/_internal/encryption/models/public_key.py new file mode 100644 index 0000000..604f085 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/models/public_key.py @@ -0,0 +1,13 @@ +"""Definitions supporting the 'core status' functionality of the core capability.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from typing import Annotated + + +class IntersectEncryptionPublicKey(BaseModel): + """Public key information for encryption within the INTERSECT-SDK Service.""" + + public_key: Annotated[str, Field(title='Public Key PEM')] + """The PEM encoded public key for asymmetric encryption.""" \ No newline at end of file diff --git a/src/intersect_sdk/_internal/encryption/service_decryption.py b/src/intersect_sdk/_internal/encryption/service_decryption.py new file mode 100644 index 0000000..2977787 --- /dev/null +++ b/src/intersect_sdk/_internal/encryption/service_decryption.py @@ -0,0 +1,57 @@ +""" +Service decryption function for intersect_sdk._internal.encryption.service_decryption +RSA asymmetric encryption and AES symmetric encryption +""" + +import json +from typing import Dict, Type +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives import hashes +import base64 + +from pydantic import BaseModel + +from .aes_cypher import AESCipher +from .models import IntersectEncryptedPayload, IntersectDecryptedPayload +from ..logger import logger + + +def intersect_payload_decrypt( + rsa_private_key: rsa.RSAPrivateKey, + encrypted_payload: IntersectEncryptedPayload, + model: Type[BaseModel], +) -> IntersectDecryptedPayload: + # Decode base64 encoded values from payload + encrypted_aes_key = base64.b64decode(encrypted_payload.key.encode()) + initial_vector = base64.b64decode(encrypted_payload.initial_vector.encode()) + encrypted_data = base64.b64decode(encrypted_payload.data.encode()) + + # Decrypt AES key using RSA + logger.info("RSA decrypting AES key...") + decrypted_aes_key: bytes = rsa_private_key.decrypt( + encrypted_aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + logger.info("RSA decrypted AES key!") + + # Encrypt using AES + logger.info("AES decrypting payload...") + cipher = AESCipher( + key=decrypted_aes_key, + initialization_vector=initial_vector, + ) + + decrypted_payload: bytes = cipher.decrypt(encrypted_data) + unencrypted_payload = decrypted_payload.decode() + logger.info("AES decrypted payload!") + + # Cast unencrypted payload to model and return AES key and IV for possible re-use + return IntersectDecryptedPayload( + model=model(**json.loads(unencrypted_payload)), + aes_key=decrypted_aes_key, + aes_initialization_vector=initial_vector, + ) diff --git a/src/intersect_sdk/_internal/function_metadata.py b/src/intersect_sdk/_internal/function_metadata.py index 2f86c0c..cc2449b 100644 --- a/src/intersect_sdk/_internal/function_metadata.py +++ b/src/intersect_sdk/_internal/function_metadata.py @@ -5,7 +5,11 @@ if TYPE_CHECKING: from pydantic import TypeAdapter - from ..core_definitions import IntersectDataHandler, IntersectMimeType + from ..core_definitions import ( + IntersectDataHandler, + IntersectEncryptionScheme, + IntersectMimeType, + ) class FunctionMetadata(NamedTuple): @@ -42,6 +46,10 @@ class FunctionMetadata(NamedTuple): """ How we intend on handling the response value """ + encryption_schemes: set[IntersectEncryptionScheme] + """ + Supported encryption schemes + """ strict_validation: bool """ Whether or not we're using lenient Pydantic validation (default, False) or strict diff --git a/src/intersect_sdk/_internal/messages/userspace.py b/src/intersect_sdk/_internal/messages/userspace.py index 3ae66b7..f2a8188 100644 --- a/src/intersect_sdk/_internal/messages/userspace.py +++ b/src/intersect_sdk/_internal/messages/userspace.py @@ -23,9 +23,7 @@ from pydantic import AwareDatetime, BaseModel, Field, field_serializer from ...constants import SYSTEM_OF_SYSTEM_REGEX -from ...core_definitions import ( - IntersectDataHandler, -) +from ...core_definitions import IntersectDataHandler, IntersectEncryptionScheme from ...version import version_string @@ -131,6 +129,11 @@ class UserspaceMessageHeaders(BaseModel): This should only be set to "True" on return messages sent by services - NEVER clients. """ + encryption_scheme: IntersectEncryptionScheme = 'NONE' + """ + The encryption scheme of the message itself. This determines the requested payload. + """ + # make sure all non-string fields are serialized into strings, even in Python code @field_serializer('message_id', 'request_id', 'campaign_id', mode='plain') @@ -157,6 +160,7 @@ def create_userspace_message_headers( data_handler: IntersectDataHandler, campaign_id: uuid.UUID, request_id: uuid.UUID, + encryption_scheme: IntersectEncryptionScheme, has_error: bool = False, ) -> dict[str, str]: """Generate raw headers and write them into a generic data structure which can be handled by any broker protocol.""" @@ -170,6 +174,7 @@ def create_userspace_message_headers( created_at=datetime.datetime.now(tz=datetime.timezone.utc), operation_id=operation_id, data_handler=data_handler, + encryption_scheme=encryption_scheme, has_error=has_error, ).model_dump(by_alias=True) diff --git a/src/intersect_sdk/_internal/schema.py b/src/intersect_sdk/_internal/schema.py index 2124686..0e101bd 100644 --- a/src/intersect_sdk/_internal/schema.py +++ b/src/intersect_sdk/_internal/schema.py @@ -23,6 +23,7 @@ from .constants import ( BASE_RESPONSE_ATTR, BASE_STATUS_ATTR, + ENCRYPTION_SCHEMES, REQUEST_CONTENT, RESPONSE_CONTENT, RESPONSE_DATA, @@ -411,6 +412,7 @@ def _introspection_baseline( request_content = getattr(method, REQUEST_CONTENT) response_content = getattr(method, RESPONSE_CONTENT) + encryption_schemes = getattr(method, ENCRYPTION_SCHEMES) docstring = inspect.cleandoc(method.__doc__) if method.__doc__ else None signature = inspect.signature(method) @@ -434,6 +436,7 @@ def _introspection_baseline( 'message': { 'schemaFormat': f'application/vnd.aai.asyncapi+json;version={ASYNCAPI_VERSION}', 'contentType': request_content, + 'encryption_schemes': sorted(encryption_schemes), 'traits': {'$ref': '#/components/messageTraits/commonHeaders'}, } }, @@ -441,6 +444,7 @@ def _introspection_baseline( 'message': { 'schemaFormat': f'application/vnd.aai.asyncapi+json;version={ASYNCAPI_VERSION}', 'contentType': response_content, + 'encryption_schemes': sorted(encryption_schemes), 'traits': {'$ref': '#/components/messageTraits/commonHeaders'}, } }, @@ -532,6 +536,7 @@ def _introspection_baseline( request_content, response_content, data_handler, + encryption_schemes, getattr(method, STRICT_VALIDATION), getattr(method, SHUTDOWN_KEYS), ) @@ -561,6 +566,7 @@ def _introspection_baseline( getattr(status_fn, REQUEST_CONTENT), getattr(status_fn, RESPONSE_CONTENT), getattr(status_fn, RESPONSE_DATA), + {'NONE'}, # status functions should always be assumed to send out unencrypted messages. getattr(status_fn, STRICT_VALIDATION), getattr(status_fn, SHUTDOWN_KEYS), ) diff --git a/src/intersect_sdk/capability/universal_capability/universal_capability.py b/src/intersect_sdk/capability/universal_capability/universal_capability.py index 0cba00a..0b2bd05 100644 --- a/src/intersect_sdk/capability/universal_capability/universal_capability.py +++ b/src/intersect_sdk/capability/universal_capability/universal_capability.py @@ -8,11 +8,12 @@ import datetime import os import time -from typing import final +from typing import Dict, final import psutil -from ...service_definitions import intersect_status +from ...service_definitions import intersect_message, intersect_status +from ..._internal.encryption.models import IntersectEncryptionPublicKey from ..base import IntersectBaseCapabilityImplementation from .status import IntersectCoreStatus @@ -36,6 +37,7 @@ def __init__(self) -> None: # noqa: D107 self.process = psutil.Process(os.getpid()) """psutil.Process caches most functions it calls after it calls the function once, so just save the object itself""" + @intersect_status def system_capability(self) -> IntersectCoreStatus: """The status of this Capability reflects core system information which is okay to broadcast across the INTERSECT-SDK system. @@ -58,3 +60,9 @@ def system_capability(self) -> IntersectCoreStatus: disk_total=disk_info.total, disk_usage_percentage=disk_info.percent, ) + + @intersect_message() + def get_public_key(self) -> Dict[str, str]: + """Returns the public key for clients / services to use for encryption""" + return IntersectEncryptionPublicKey(public_key=self._public_key_pem) + diff --git a/src/intersect_sdk/client.py b/src/intersect_sdk/client.py index 66974c0..89080d7 100644 --- a/src/intersect_sdk/client.py +++ b/src/intersect_sdk/client.py @@ -12,6 +12,8 @@ from __future__ import annotations +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization import time from typing import TYPE_CHECKING from uuid import uuid4 @@ -19,7 +21,12 @@ from pydantic import ValidationError from typing_extensions import Self, final -from intersect_sdk._internal.generic_serializer import GENERIC_MESSAGE_SERIALIZER +from ._internal.encryption.models.public_key import IntersectEncryptionPublicKey +from ._internal.encryption import ( + intersect_payload_decrypt, + intersect_payload_encrypt, +) +from ._internal.generic_serializer import GENERIC_MESSAGE_SERIALIZER from ._internal.control_plane.control_plane_manager import ( ControlPlaneManager, @@ -59,6 +66,7 @@ class IntersectClient: - startup() - shutdown() - is_connected() + - considered_unrecoverable() No other functions or parameters are guaranteed to remain stable. @@ -148,6 +156,24 @@ def __init__( self._campaign_id = uuid4() + # Generate a key pair for encryption + self._private_key: rsa.RSAPrivateKey = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + self._public_key = self._private_key.public_key() + + # Get the PEM encoded public key + self._public_key_pem = self._public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + @final def startup(self) -> Self: """This function connects the client to all INTERSECT systems. @@ -224,6 +250,69 @@ def considered_unrecoverable(self) -> bool: """ return self._control_plane_manager.considered_unrecoverable() + def _fetch_service_public_key(self, destination: str) -> str | None: + """Fetch the public key from a destination service via intersect_sdk.get_public_key. + + Args: + destination: The hierarchy string of the destination service + + Returns: + The PEM-encoded public key from the service, or None if fetching failed + """ + # Create a temporary request ID for tracking the public key request + public_key_request_id = uuid4() + + # Build and send the public key request + headers = create_userspace_message_headers( + source=self._hierarchy.hierarchy_string('.'), + destination=destination, + operation_id='intersect_sdk.get_public_key', + campaign_id=uuid4(), + request_id=public_key_request_id, + encryption_scheme='NONE', + ) + + # Send the request to get the public key + request_channel = f'{destination.replace(".", "/")}/request' + self._control_plane_manager.publish_message( + request_channel, b'null', 'application/json', headers, persist=False + ) + + # Poll for the response with timeout + start_time = time.time() + timeout = 30.0 + poll_interval = 0.1 + response_data = None + + while time.time() - start_time < timeout: + # Check if we have received a response for this request + # We'll store responses temporarily in a dict keyed by request_id + if hasattr(self, '_public_key_responses') and str(public_key_request_id) in self._public_key_responses: + response_data = self._public_key_responses.pop(str(public_key_request_id)) + break + time.sleep(poll_interval) + + if response_data is None: + logger.error(f'Failed to retrieve public key from {destination} within timeout') + return None + + # Extract the public key from the response + try: + if isinstance(response_data, dict): + public_key = response_data.get('public_key') + else: + # If it's already parsed as IntersectEncryptionPublicKey + public_key = response_data.public_key if hasattr(response_data, 'public_key') else None + + if public_key is None: + logger.error(f'No public_key field in response from {destination}') + return None + + return public_key + except (KeyError, AttributeError) as e: + logger.error(f'Failed to extract public key from response: {e}') + return None + def _handle_userspace_message( self, payload: bytes, content_type: str, raw_headers: dict[str, str] ) -> None: @@ -264,6 +353,32 @@ def _handle_userspace_message( request_params = self._data_plane_manager.incoming_message_data_handler( payload, headers.data_handler ) + + # Check if this is a response to a public key request (before processing as user message) + if headers.operation_id == 'intersect_sdk.get_public_key': + if not hasattr(self, '_public_key_responses'): + self._public_key_responses = {} + self._public_key_responses[str(headers.message_id)] = request_params + return + + if not headers.has_error: + match headers.encryption_scheme: + case 'RSA': + from pydantic import BaseModel + + # Create a simple wrapper model for bytes deserialization + class _BytesWrapper(BaseModel): + data: str = "" + + decrypted = intersect_payload_decrypt( + rsa_private_key=self._private_key, + encrypted_payload=request_params, + model=_BytesWrapper, + ) + # Extract the decrypted data and return as bytes + request_params = decrypted.model.data.encode() + case _: + pass if content_type == 'application/json': request_params = GENERIC_MESSAGE_SERIALIZER.validate_json(request_params) except ValidationError as e: @@ -417,7 +532,27 @@ def _send_userspace_message(self, params: IntersectDirectMessageParams) -> None: return serialized_msg = params.payload - # TWO: SEND DATA TO APPROPRIATE DATA STORE + # TWO: encrypt message + match params.encryption_scheme: + case 'RSA': + # Fetch the destination service's public key + public_key_pem = self._fetch_service_public_key(params.destination) + if public_key_pem is None: + logger.error(f'Failed to fetch public key from {params.destination}') + return + + # Convert bytes to string if needed (since intersect_payload_encrypt expects a string) + unencrypted_string = serialized_msg if isinstance(serialized_msg, str) else serialized_msg.decode() + serialized_msg = intersect_payload_encrypt( + key_payload=IntersectEncryptionPublicKey( + public_key=public_key_pem + ), + unencrypted_model=unencrypted_string, + ) + case _: + pass + + # THREE: SEND DATA TO APPROPRIATE DATA STORE try: payload = self._data_plane_manager.outgoing_message_data_handler( serialized_msg, params.content_type, params.data_handler @@ -429,7 +564,7 @@ def _send_userspace_message(self, params: IntersectDirectMessageParams) -> None: send_os_signal() return - # THREE: SEND MESSAGE + # FOUR: SEND MESSAGE headers = create_userspace_message_headers( source=self._hierarchy.hierarchy_string('.'), destination=params.destination, @@ -437,6 +572,7 @@ def _send_userspace_message(self, params: IntersectDirectMessageParams) -> None: operation_id=params.operation, campaign_id=self._campaign_id, request_id=uuid4(), + encryption_scheme=params.encryption_scheme, ) logger.debug(f'Send userspace message:\n{headers}') channel = f'{params.destination.replace(".", "/")}/request' diff --git a/src/intersect_sdk/core_definitions.py b/src/intersect_sdk/core_definitions.py index b9b0047..93f3a50 100644 --- a/src/intersect_sdk/core_definitions.py +++ b/src/intersect_sdk/core_definitions.py @@ -1,7 +1,7 @@ """Core enumerations and structures used throughout INTERSECT, for both client and service.""" from enum import Enum -from typing import Annotated +from typing import Annotated, Literal from pydantic import Field @@ -45,3 +45,6 @@ class IntersectDataHandler(Enum): - If your Content-Type value is ANYTHING ELSE, you MUST mark it as "bytes" . In this instance, INTERSECT will not base64-encode or base64-decode the value. """ + +IntersectEncryptionScheme = Literal['NONE', 'RSA'] +"""Supported encryption schemes throughout INTERSECT. 'NONE' implies no encryption scheme.""" diff --git a/src/intersect_sdk/service.py b/src/intersect_sdk/service.py index d305d51..606f5d7 100644 --- a/src/intersect_sdk/service.py +++ b/src/intersect_sdk/service.py @@ -18,6 +18,8 @@ import time from collections import defaultdict +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization from threading import Lock from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal @@ -31,6 +33,8 @@ ControlPlaneManager, ) from ._internal.data_plane.data_plane_manager import DataPlaneManager +from ._internal.encryption import intersect_payload_decrypt, intersect_payload_encrypt +from ._internal.encryption.models import IntersectEncryptionPublicKey from ._internal.exceptions import IntersectApplicationError, IntersectError from ._internal.generic_serializer import GENERIC_MESSAGE_SERIALIZER from ._internal.interfaces import IntersectEventObserver @@ -308,6 +312,24 @@ def __init__( self._client_channel_name, {self._handle_client_message}, persist=True ) + # Generate a key pair for encryption + self._private_key: rsa.RSAPrivateKey = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + self._public_key = self._private_key.public_key() + + # Get the PEM encoded public key + self._public_key_pem = self._public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + def _get_capability(self, target: str) -> IntersectBaseCapabilityImplementation | None: for cap in self.capabilities: if cap.intersect_sdk_capability_name == target: @@ -815,12 +837,31 @@ def _handle_service_message_inner( self._make_error_message_headers(headers), ) + # FOUR: DECRYPT DATA + if not headers.has_error: + if headers.encryption_scheme not in operation_meta.encryption_schemes: + return ( + f'Invalid encryption scheme {headers.encryption_scheme}, supported encryption schemes are: {operation_meta.encryption_schemes}'.encode(), + 'text/plain', + self._make_error_message_headers(headers), + ) + match headers.encryption_scheme: + case 'RSA': + request_params = intersect_payload_decrypt( + rsa_private_key=self._private_key, + encrypted_payload=request_params, + model=bytes, + ) + pass + case _: + pass + try: - # FOUR: CALL USER FUNCTION AND GET MESSAGE + # FIVE: CALL USER FUNCTION AND GET MESSAGE response = self._call_user_function( target_capability, operation_method, operation_meta, request_params ) - # FIVE: SEND DATA TO APPROPRIATE DATA STORE + # SIX: SEND DATA TO APPROPRIATE DATA STORE response_data_handler = operation_meta.response_data_transfer_handler response_content_type = operation_meta.response_content_type response_payload = self._data_plane_manager.outgoing_message_data_handler( @@ -854,7 +895,7 @@ def _handle_service_message_inner( self._make_error_message_headers(headers), ) - # SIX: SEND MESSAGE + # SEVEN: SEND MESSAGE response_headers = create_userspace_message_headers( source=headers.destination, destination=headers.source, @@ -862,6 +903,7 @@ def _handle_service_message_inner( operation_id=headers.operation_id, request_id=headers.request_id, campaign_id=headers.campaign_id, + encryption_scheme=headers.encryption_scheme, ) return (response_payload, response_content_type, response_headers) @@ -884,36 +926,47 @@ def _handle_client_message( extreq = self._get_external_request(headers.message_id) if extreq is not None: error_msg: str | None = None - try: - msg_payload = self._data_plane_manager.incoming_message_data_handler( - payload, headers.data_handler - ) - if content_type == 'application/json': - msg_payload = GENERIC_MESSAGE_SERIALIZER.validate_json(msg_payload) - except ValidationError as e: - error_msg = f'Service sent back invalid response:\n{e}' - logger.warning(error_msg) - except IntersectError: - error_msg = 'INTERNAL ERROR: failed to get message payload from data handler' - logger.error(error_msg) - - if error_msg: - # we did not get a valid INTERSECT message back, so just mark it for cleanup - extreq.request_state = 'finalized' + if ( + not headers.has_error + and headers.encryption_scheme != extreq.request.encryption_scheme + ): + error_msg = f'Invalid encryption scheme {headers.encryption_scheme}, expected {extreq.request.encryption_scheme}' + self._data_plane_manager.remove_remote_data(payload, headers.data_handler) elif ( extreq.request.destination != headers.source or extreq.request.operation != headers.operation_id ): - logger.warning( - 'Possible spoof message, discarding. Target destination', - extreq.request.destination, - 'Actual source', - headers.source, - 'Target operation', - extreq.request.operation, - 'Actual operation', - headers.operation_id, - ) + error_msg = f'Possible spoof message, discarding. Target destination: {extreq.request.destination} -- actual source: {headers.source} -- target operation: {extreq.request.operation} -- actual operation: {headers.operation_id}' + self._data_plane_manager.remove_remote_data(payload, headers.data_handler) + logger.warning(error_msg) + else: + try: + msg_payload = self._data_plane_manager.incoming_message_data_handler( + payload, headers.data_handler + ) + if not headers.has_error: + # error messages should never be encrypted + match headers.encryption_scheme: + case 'RSA': + msg_payload = intersect_payload_decrypt( + rsa_private_key=self._private_key, + encrypted_payload=msg_payload, + model=bytes, + ) + pass + case _: + pass + if content_type == 'application/json': + msg_payload = GENERIC_MESSAGE_SERIALIZER.validate_json(msg_payload) + except ValidationError as e: + error_msg = f'Service sent back invalid response:\n{e}' + logger.warning(error_msg) + except IntersectError: + error_msg = 'INTERNAL ERROR: failed to get message payload from data handler' + logger.error(error_msg) + + if error_msg: + # we did not get a valid INTERSECT message back, so just mark it for cleanup extreq.request_state = 'finalized' else: # success @@ -931,13 +984,69 @@ def _send_client_message(self, request_id: UUID, params: IntersectDirectMessageP if params.content_type == 'application/json': request = GENERIC_MESSAGE_SERIALIZER.dump_json(params.payload, warnings=False) else: - if not isinstance(params.content_type, bytes): + if not isinstance(params.payload, bytes): logger.error( 'service-to-service message must be bytes if content-type is not application/json' ) return False request = params.payload + match params.encryption_scheme: + case 'RSA': + # Get the public key from the destination service + # The destination service has the public key through its universal capability + public_key_request = IntersectDirectMessageParams( + destination=params.destination, + operation='intersect_sdk.get_public_key', + payload=None, + ) + + # Create the external request to fetch the public key + public_key_request_id = self.create_external_request( + public_key_request, + response_handler=None, + timeout=30.0, # shorter timeout for key fetching + ) + + # Poll for the response with timeout + start_time = time.time() + timeout = 30.0 + poll_interval = 0.1 + + public_key_payload = None + while time.time() - start_time < timeout: + extreq = self._get_external_request(public_key_request_id) + if extreq and extreq.request_state == 'received': + public_key_payload = extreq.response_payload + extreq.request_state = 'finalized' + break + time.sleep(poll_interval) + + if public_key_payload is None: + logger.error( + f'Failed to retrieve public key from {params.destination} within timeout' + ) + return False + + # public_key_payload should be an IntersectEncryptionPublicKey instance (as a dict) + # Parse it if it's a dict, otherwise use it as-is + if isinstance(public_key_payload, dict): + key_payload = IntersectEncryptionPublicKey(**public_key_payload) + else: + key_payload = public_key_payload + + # Now encrypt the request using the retrieved public key + # Convert bytes back to string if needed (since intersect_payload_encrypt expects a string) + unencrypted_string = request if isinstance(request, str) else request.decode() + request = intersect_payload_encrypt( + key_payload=IntersectEncryptionPublicKey( + public_key=key_payload.public_key, + ), + unencrypted_model=unencrypted_string, + ) + case _: + pass + # TWO: SEND DATA TO APPROPRIATE DATA STORE try: request_payload = self._data_plane_manager.outgoing_message_data_handler( @@ -954,6 +1063,7 @@ def _send_client_message(self, request_id: UUID, params: IntersectDirectMessageP operation_id=params.operation, request_id=request_id, campaign_id=self._campaign_id, + encryption_scheme=params.encryption_scheme, ) logger.debug(f'Sending client message:\n{headers}') request_channel = f'{params.destination.replace(".", "/")}/request' @@ -1154,6 +1264,7 @@ def _make_error_message_headers( operation_id=original_headers.operation_id, campaign_id=original_headers.campaign_id, request_id=original_headers.request_id, + encryption_scheme='NONE', has_error=True, ) diff --git a/src/intersect_sdk/service_definitions.py b/src/intersect_sdk/service_definitions.py index ece3391..be46809 100644 --- a/src/intersect_sdk/service_definitions.py +++ b/src/intersect_sdk/service_definitions.py @@ -13,7 +13,7 @@ import functools from collections.abc import Callable, Mapping, Sequence -from typing import Any +from typing import Any, get_args from pydantic import BaseModel, ConfigDict, field_validator, validate_call from typing_extensions import final @@ -21,13 +21,14 @@ from ._internal.constants import ( BASE_RESPONSE_ATTR, BASE_STATUS_ATTR, + ENCRYPTION_SCHEMES, REQUEST_CONTENT, RESPONSE_CONTENT, RESPONSE_DATA, SHUTDOWN_KEYS, STRICT_VALIDATION, ) -from .core_definitions import IntersectDataHandler, IntersectMimeType +from .core_definitions import IntersectDataHandler, IntersectEncryptionScheme, IntersectMimeType @final @@ -85,6 +86,7 @@ def intersect_message( response_data_transfer_handler: IntersectDataHandler = IntersectDataHandler.MESSAGE, response_content_type: IntersectMimeType = 'application/json', strict_request_validation: bool = False, + encryption_schemes: set[IntersectEncryptionScheme] | None = None, ) -> Callable[..., Any]: """Use this annotation to mark your capability method as an entrypoint to external requests. @@ -133,6 +135,8 @@ def some_external_function(self, request: MyBaseModelRequest) -> MyBaseModelResp - strict_request_validation: if this is set to True, use pydantic strict validation for requests - otherwise, use lenient validation (default: False) See https://docs.pydantic.dev/latest/concepts/conversion_table/ for more info about this. NOTE: If you are using a Mapping type (i.e. Dict) with integer or float keys, you MUST leave this on False. + - encryption_schemes: This is a set of the encryption schemes the endpoint will support. If no encryption scheme is specified, all encryption schemes (including no encryption scheme) will be supported. + This is a way to either enforce encrypted data for a specific endpoint, or to enforce that a specific endpoint is _not_ encrypted. """ def inner_decorator(func: Callable[..., Any]) -> Callable[..., Any]: @@ -153,6 +157,13 @@ def __intersect_sdk_wrapper(*args: Any, **kwargs: Any) -> Any: setattr(__intersect_sdk_wrapper, RESPONSE_DATA, response_data_transfer_handler) setattr(__intersect_sdk_wrapper, STRICT_VALIDATION, strict_request_validation) setattr(__intersect_sdk_wrapper, SHUTDOWN_KEYS, set(ignore_keys) if ignore_keys else set()) + setattr( + __intersect_sdk_wrapper, + ENCRYPTION_SCHEMES, + set(encryption_schemes) + if encryption_schemes + else set(get_args(IntersectEncryptionScheme)), + ) return __intersect_sdk_wrapper @@ -178,6 +189,7 @@ def intersect_status( A status message MUST NOT send events out. It should be a simple query of the general service (no specifics). A status message MUST send its response back in a value which can be serialized into JSON. A status message MUST have a fairly small response size (no large data). + A status message MUST be able to be sent out unencrypted (encryption is not supported). Use @intersect_message if you want to manually get status information which is potentially sensitive. """ def inner_decorator(func: Callable[..., Any]) -> Callable[..., Any]: diff --git a/src/intersect_sdk/shared_callback_definitions.py b/src/intersect_sdk/shared_callback_definitions.py index 4172cc9..a0b37e2 100644 --- a/src/intersect_sdk/shared_callback_definitions.py +++ b/src/intersect_sdk/shared_callback_definitions.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from .constants import CAPABILITY_REGEX, SYSTEM_OF_SYSTEM_REGEX -from .core_definitions import IntersectDataHandler, IntersectMimeType +from .core_definitions import IntersectDataHandler, IntersectEncryptionScheme, IntersectMimeType INTERSECT_JSON_VALUE: TypeAlias = ( list['INTERSECT_JSON_VALUE'] @@ -70,6 +70,13 @@ class IntersectDirectMessageParams(BaseModel): default: IntersectDataHandler.MESSAGE """ + encryption_scheme: IntersectEncryptionScheme = 'NONE' + """ + The encryption scheme used for messaging. + + default: 'NONE' + """ + # pydantic config model_config = ConfigDict(revalidate_instances='always') diff --git a/tests/e2e/test_examples.py b/tests/e2e/test_examples.py index 33dd880..1516dc1 100644 --- a/tests/e2e/test_examples.py +++ b/tests/e2e/test_examples.py @@ -150,3 +150,8 @@ def test_example_4_service_to_service_events(): 'From event "internal_service_event", received message "not_feeling_creative" from "example-organization.example-facility.example-system.example-subsystem.internal-service"\n' 'From event "internal_service_event", received message "not_feeling_creative" from "example-organization.example-facility.example-system.example-subsystem.internal-service"\n' ) + + +def test_example_5_hello_world_rsa(): + """Test hello world example with RSA encryption.""" + assert run_example_test('5_hello_world_rsa') == 'Hello, hello_client!\n' diff --git a/tests/fixtures/example_schema.json b/tests/fixtures/example_schema.json index fc617d6..d53d5ec 100644 --- a/tests/fixtures/example_schema.json +++ b/tests/fixtures/example_schema.json @@ -9,7 +9,45 @@ "defaultContentType": "application/json", "capabilities": { "intersect_sdk": { - "endpoints": {}, + "endpoints": { + "get_public_key": { + "publish": { + "message": { + "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", + "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], + "traits": { + "$ref": "#/components/messageTraits/commonHeaders" + }, + "payload": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "get_public_key" + } + }, + "description": "Returns the public key for clients / services to use for encryption" + }, + "subscribe": { + "message": { + "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", + "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], + "traits": { + "$ref": "#/components/messageTraits/commonHeaders" + } + }, + "description": "Returns the public key for clients / services to use for encryption" + } + } + }, "events": {}, "status": { "$ref": "#/components/schemas/IntersectCoreStatus" @@ -23,6 +61,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -43,6 +85,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -65,6 +111,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "image/png", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -80,6 +130,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "image/png", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -98,6 +152,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -115,6 +173,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -133,6 +195,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -150,6 +216,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -176,6 +246,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -189,6 +263,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -204,6 +282,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -231,6 +313,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -249,6 +335,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -264,6 +354,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -281,6 +375,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -294,6 +392,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -311,6 +413,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -324,6 +430,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" } @@ -335,6 +445,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -348,6 +462,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" } @@ -359,6 +477,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -373,6 +495,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -388,6 +514,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -402,6 +532,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -419,6 +553,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -433,6 +571,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -456,6 +598,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -473,6 +619,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -492,6 +642,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -506,6 +660,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -521,6 +679,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -538,6 +700,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -554,6 +720,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -575,6 +745,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -593,6 +767,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -605,6 +783,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -619,6 +801,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -633,6 +819,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -650,6 +840,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -670,6 +864,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -689,6 +887,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -729,6 +931,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" } @@ -741,6 +947,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -755,6 +965,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -772,6 +986,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -791,6 +1009,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -812,6 +1034,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -825,6 +1051,10 @@ "message": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=2.6.0", "contentType": "application/json", + "encryption_schemes": [ + "NONE", + "RSA" + ], "traits": { "$ref": "#/components/messageTraits/commonHeaders" }, @@ -1201,6 +1431,15 @@ "description": "If this value is True, the payload will contain the error message (a string)", "title": "Has Error", "type": "string" + }, + "encryption_scheme": { + "default": "NONE", + "enum": [ + "NONE", + "RSA" + ], + "title": "Encryption Scheme", + "type": "string" } }, "required": [ @@ -1272,4 +1511,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/integration/test_return_type_mismatch.py b/tests/integration/test_return_type_mismatch.py index cf57a2a..bcbd2ce 100644 --- a/tests/integration/test_return_type_mismatch.py +++ b/tests/integration/test_return_type_mismatch.py @@ -115,6 +115,7 @@ def userspace_msg_callback( operation_id='ReturnTypeMismatchCapability.wrong_return_annotation', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) diff --git a/tests/integration/test_rsa_encryption.py b/tests/integration/test_rsa_encryption.py new file mode 100644 index 0000000..df94d68 --- /dev/null +++ b/tests/integration/test_rsa_encryption.py @@ -0,0 +1,381 @@ +""" +Integration tests for RSA encryption between service and client. + +These tests verify end-to-end RSA encryption functionality including: +- Service-to-client encrypted responses +- Client-to-service encrypted requests +- Public key exchange mechanism +- Decryption of encrypted messages + +To run these integration tests, you will need a connection to the broker and MINIO. + +NOTE: do NOT write assert statements in the callbacks; if the assert fails, the test will hang +instead, initialize an array with one value in it, then change the value inside the callback +""" + +import time +from uuid import uuid4 + +from pydantic import BaseModel + +from intersect_sdk import ( + ControlPlaneConfig, + DataStoreConfig, + DataStoreConfigMap, + IntersectClient, + IntersectDataHandler, + IntersectService, + IntersectServiceConfig, +) +from intersect_sdk._internal.control_plane.control_plane_manager import ControlPlaneManager +from intersect_sdk._internal.messages.userspace import ( + create_userspace_message_headers, + validate_userspace_message_headers, +) +from tests.fixtures.example_schema import FAKE_HIERARCHY_CONFIG, DummyCapabilityImplementation +from cryptography.hazmat.primitives import serialization + +# HELPERS ############################# + +def make_intersect_service_with_rsa() -> IntersectService: + """Create an IntersectService with RSA encryption enabled. + + Note: RSA keys are automatically generated internally by the service. + """ + return IntersectService( + [DummyCapabilityImplementation()], + IntersectServiceConfig( + hierarchy=FAKE_HIERARCHY_CONFIG, + data_stores=DataStoreConfigMap( + minio=[ + DataStoreConfig( + username='AKIAIOSFODNN7EXAMPLE', + password='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + port=9000, + ) + ] + ), + brokers=[ + ControlPlaneConfig( + username='intersect_username', + password='intersect_password', + port=1883, + protocol='mqtt5.0', + ), + ], + status_interval=30.0, + ), + ) + + +def make_intersect_client_with_rsa() -> IntersectClient: + """Create an IntersectClient with RSA encryption enabled. + + Note: RSA keys are automatically generated internally by the client. + """ + return IntersectClient( + FAKE_HIERARCHY_CONFIG, + config=IntersectServiceConfig( + hierarchy=FAKE_HIERARCHY_CONFIG, + data_stores=DataStoreConfigMap( + minio=[ + DataStoreConfig( + username='AKIAIOSFODNN7EXAMPLE', + password='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + port=9000, + ) + ] + ), + brokers=[ + ControlPlaneConfig( + username='intersect_username', + password='intersect_password', + port=1883, + protocol='mqtt5.0', + ), + ], + ), + ) + + +def make_message_interceptor() -> ControlPlaneManager: + return ControlPlaneManager( + [ + ControlPlaneConfig( + username='intersect_username', + password='intersect_password', + port=1883, + protocol='mqtt5.0', + ) + ], + ) + + +# TESTS ################ + + +def test_service_responds_with_rsa_encryption() -> None: + """Test that service can respond to requests using RSA encryption.""" + intersect_service = make_intersect_service_with_rsa() + message_interceptor = make_message_interceptor() + msg = [None, None, None] + + campaign_id = uuid4() + request_id = uuid4() + + def userspace_msg_callback( + payload: bytes, content_type: str, raw_headers: dict[str, str] + ) -> None: + msg[0] = payload + msg[1] = content_type + msg[2] = validate_userspace_message_headers(raw_headers) + + message_interceptor.add_subscription_channel( + 'msg/msg/msg/msg/msg/response', {userspace_msg_callback}, False + ) + message_interceptor.connect() + intersect_service.startup() + time.sleep(1.0) + + # Send a message requesting RSA encryption + message_interceptor.publish_message( + intersect_service._service_channel_name, + b'[4,6]', + 'application/json', + create_userspace_message_headers( + source='msg.msg.msg.msg.msg', + destination='test.test.test.test.test', + data_handler=IntersectDataHandler.MESSAGE, + operation_id='DummyCapability.calculate_fibonacci', + campaign_id=campaign_id, + request_id=request_id, + encryption_scheme='RSA', + ), + True, + ) + time.sleep(3.0) + intersect_service.shutdown() + message_interceptor.disconnect() + + # Verify response was received + assert msg[0] is not None + assert msg[2] is not None + # Verify encryption scheme was RSA + assert msg[2]['encryption_scheme'] == 'RSA' + # Verify header IDs were not modified + assert msg[2]['request_id'] == request_id + assert msg[2]['campaign_id'] == campaign_id + + +def test_service_fetches_client_public_key_for_rsa_response() -> None: + """Test that service fetches client's public key before sending encrypted response.""" + intersect_service = make_intersect_service_with_rsa() + message_interceptor = make_message_interceptor() + + public_key_request_received = [False] + response_msg = [None, None, None] + + def public_key_request_callback( + payload: bytes, content_type: str, raw_headers: dict[str, str] + ) -> None: + headers = validate_userspace_message_headers(raw_headers) + if headers['operation_id'] == 'intersect_sdk.get_public_key': + public_key_request_received[0] = True + # Respond with a public key + public_key_pem = intersect_service._private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + message_interceptor.publish_message( + 'msg/msg/msg/msg/msg/response', + f'{{"public_key": "{public_key_pem}"}}'.encode(), + 'application/json', + create_userspace_message_headers( + source='test.test.test.test.test', + destination='msg.msg.msg.msg.msg', + data_handler=IntersectDataHandler.MESSAGE, + operation_id='intersect_sdk.get_public_key', + campaign_id=headers['campaign_id'], + request_id=headers['request_id'], + encryption_scheme='NONE', + ), + True, + ) + + def response_callback( + payload: bytes, content_type: str, raw_headers: dict[str, str] + ) -> None: + headers = validate_userspace_message_headers(raw_headers) + if headers['operation_id'] == 'DummyCapability.calculate_fibonacci': + response_msg[0] = payload + response_msg[1] = content_type + response_msg[2] = headers + + # Subscribe to both the request channel (to intercept public key requests) and response channel + message_interceptor.add_subscription_channel( + 'msg/msg/msg/msg/msg/request', {public_key_request_callback}, False + ) + message_interceptor.add_subscription_channel( + 'msg/msg/msg/msg/msg/response', {response_callback}, False + ) + message_interceptor.connect() + intersect_service.startup() + time.sleep(1.0) + + # Send a message requesting RSA encryption + message_interceptor.publish_message( + intersect_service._service_channel_name, + b'[4,6]', + 'application/json', + create_userspace_message_headers( + source='msg.msg.msg.msg.msg', + destination='test.test.test.test.test', + data_handler=IntersectDataHandler.MESSAGE, + operation_id='DummyCapability.calculate_fibonacci', + campaign_id=uuid4(), + request_id=uuid4(), + encryption_scheme='RSA', + ), + True, + ) + time.sleep(3.0) + intersect_service.shutdown() + message_interceptor.disconnect() + + # Verify that the service requested the client's public key + assert public_key_request_received[0] is True + # Verify that a response was sent + assert response_msg[0] is not None + + +def test_client_sends_rsa_encrypted_message() -> None: + """Test that client can send RSA encrypted messages to service.""" + intersect_service = make_intersect_service_with_rsa() + intersect_client = make_intersect_client_with_rsa() + + response_received = [None] + + def user_callback(source: str, operation: str, has_error: bool, payload: dict) -> None: + response_received[0] = payload + + intersect_service.startup() + time.sleep(1.0) + + intersect_client.startup(user_callback=user_callback) + time.sleep(1.0) + + # Send encrypted message from client to service + from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams + + params = IntersectDirectMessageParams( + destination='test.test.test.test.test', + operation='DummyCapability.calculate_fibonacci', + payload=(4, 6), + content_type='application/json', + encryption_scheme='RSA', + ) + + intersect_client.send_message(params) + time.sleep(3.0) + + intersect_client.shutdown() + intersect_service.shutdown() + + # Verify response was received + assert response_received[0] is not None + # Verify the decrypted response is correct + assert response_received[0] == [5, 8, 13] + + +def test_client_receives_rsa_encrypted_response() -> None: + """Test that client can receive and decrypt RSA encrypted responses from service.""" + intersect_service = make_intersect_service_with_rsa() + intersect_client = make_intersect_client_with_rsa() + + response_received = [None] + encryption_scheme_used = [None] + + def user_callback(source: str, operation: str, has_error: bool, payload: dict) -> None: + response_received[0] = payload + # Note: we can't directly check encryption_scheme here as it's not passed to callback + # but we can verify the payload was properly decrypted + + intersect_service.startup() + time.sleep(1.0) + + intersect_client.startup(user_callback=user_callback) + time.sleep(1.0) + + # Send request with RSA encryption (service should respond with RSA) + from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams + + params = IntersectDirectMessageParams( + destination='test.test.test.test.test', + operation='DummyCapability.calculate_fibonacci', + payload=(10, 12), + content_type='application/json', + encryption_scheme='RSA', + ) + + intersect_client.send_message(params) + time.sleep(3.0) + + intersect_client.shutdown() + intersect_service.shutdown() + + # Verify response was received and properly decrypted + assert response_received[0] is not None + assert response_received[0] == [89, 144, 233] + + +def test_bidirectional_rsa_encryption() -> None: + """Test that both client and service can exchange RSA encrypted messages.""" + intersect_service = make_intersect_service_with_rsa() + intersect_client = make_intersect_client_with_rsa() + + responses = [] + + def user_callback(source: str, operation: str, has_error: bool, payload: dict) -> None: + responses.append({ + 'source': source, + 'operation': operation, + 'payload': payload, + 'has_error': has_error, + }) + + intersect_service.startup() + time.sleep(1.0) + + intersect_client.startup(user_callback=user_callback) + time.sleep(1.0) + + from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams + + # Send multiple encrypted messages + test_cases = [ + (4, 6, [5, 8, 13]), + (0, 2, [1, 2, 3]), + (10, 15, [89, 144, 233, 377, 610, 987]), + ] + + for start, end, expected in test_cases: + params = IntersectDirectMessageParams( + destination='test.test.test.test.test', + operation='DummyCapability.calculate_fibonacci', + payload=(start, end), + content_type='application/json', + encryption_scheme='RSA', + ) + intersect_client.send_message(params) + time.sleep(2.0) + + intersect_client.shutdown() + intersect_service.shutdown() + + # Verify all responses were received and properly decrypted + assert len(responses) == 3 + for idx, (start, end, expected) in enumerate(test_cases): + assert responses[idx]['payload'] == expected + assert responses[idx]['has_error'] is False + assert responses[idx]['operation'] == 'DummyCapability.calculate_fibonacci' diff --git a/tests/integration/test_service.py b/tests/integration/test_service.py index e7f0b53..5144ab5 100644 --- a/tests/integration/test_service.py +++ b/tests/integration/test_service.py @@ -134,6 +134,7 @@ def userspace_msg_callback( operation_id='DummyCapability.calculate_fibonacci', campaign_id=campaign_id, request_id=request_id, + encryption_scheme='NONE', ), True, ) @@ -177,6 +178,7 @@ def userspace_msg_callback( operation_id='DummyCapability.test_generator', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -216,6 +218,7 @@ def userspace_msg_callback( operation_id='DummyCapability.valid_default_argument', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -256,6 +259,7 @@ def userspace_msg_callback( operation_id='DummyCapability.calculate_fibonacci', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -299,6 +303,7 @@ def userspace_msg_callback( operation_id='DummyCapability.THIS_FUNCTION_DOES_NOT_EXIST', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -339,6 +344,7 @@ def userspace_msg_callback( operation_id='DummyCapability.divide_by_zero_exceptions', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -354,6 +360,7 @@ def userspace_msg_callback( operation_id='DummyCapability.divide_by_zero_exceptions', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -369,6 +376,7 @@ def userspace_msg_callback( operation_id='DummyCapability.raise_exception_no_param', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -417,6 +425,7 @@ def userspace_msg_callback( operation_id='DummyCapability.test_datetime', campaign_id=uuid4(), request_id=uuid4(), + encryption_scheme='NONE', ), True, ) @@ -470,6 +479,7 @@ def lifecycle_msg_callback( destination='test.test.test.test.test', data_handler=IntersectDataHandler.MESSAGE, operation_id='DummyCapability.verify_float_dict', + encryption_scheme='NONE', # note that the dict key MUST be a string, even though the input wants a float key campaign_id=uuid4(), request_id=uuid4(), diff --git a/tests/unit/test_client_rsa_encryption.py b/tests/unit/test_client_rsa_encryption.py new file mode 100644 index 0000000..582b3f1 --- /dev/null +++ b/tests/unit/test_client_rsa_encryption.py @@ -0,0 +1,310 @@ +""" +Unit tests for RSA encryption in IntersectClient + +Tests cover the client-side RSA encryption flow where a client: +1. Fetches the service's public key before sending encrypted messages +2. Encrypts outgoing messages using the service's public key +3. Decrypts incoming messages using its own private key +""" +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from pydantic import BaseModel +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +from intersect_sdk._internal.encryption import ( + intersect_payload_decrypt, + intersect_payload_encrypt, +) +from intersect_sdk._internal.encryption.models import IntersectEncryptedPayload +from intersect_sdk._internal.messages.userspace import create_userspace_message_headers +from intersect_sdk._internal.encryption.models import IntersectEncryptionPublicKey +from intersect_sdk.client import IntersectClient +from intersect_sdk.core_definitions import IntersectDataHandler +from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams + + +class SampleMessage(BaseModel): + """Simple test message for encryption/decryption testing""" + result: str = "" + + +@pytest.fixture +def rsa_keypair(): + """Generate RSA key pair for testing""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + public_key = private_key.public_key() + return private_key, public_key + + +@pytest.fixture +def public_key_pem(rsa_keypair): + """Get PEM encoded public key""" + _, public_key = rsa_keypair + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + return public_pem + + +@pytest.fixture +def mock_client(rsa_keypair): + """Create a mock IntersectClient for testing""" + mock_cli = MagicMock(spec=IntersectClient) + private_key, _ = rsa_keypair + mock_cli._private_key = private_key + mock_cli._hierarchy = MagicMock() + mock_cli._hierarchy.hierarchy_string.return_value = "test.org-fac.sys-client" + mock_cli._hierarchy.hierarchy_string.side_effect = lambda sep='.': "test.org-fac.sys-client".replace('.', sep) if sep != '.' else "test.org-fac.sys-client" + mock_cli._terminate_after_initial_messages = False + mock_cli._campaign_id = uuid4() + mock_cli._user_callback = MagicMock() + + # Mock data plane manager + mock_cli._data_plane_manager = MagicMock() + + # Mock control plane manager + mock_cli._control_plane_manager = MagicMock() + + return mock_cli + + +def test_send_message_encryption_scheme_none_skips_encryption(mock_client): + """Test that encryption_scheme='NONE' skips encryption for outgoing messages""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', + content_type="application/json", + encryption_scheme="NONE", + ) + + mock_client._data_plane_manager.outgoing_message_data_handler.return_value = ( + b"serialized_data" + ) + + # Call the send method + IntersectClient._send_userspace_message(mock_client, params) + + # Verify publish_message was called + assert mock_client._control_plane_manager.publish_message.called + call_args = mock_client._control_plane_manager.publish_message.call_args + # The payload should not be encrypted (not an IntersectEncryptedPayload instance) + payload = call_args[0][1] + assert payload == b"serialized_data" + +def test_send_message_rsa_fetches_public_key(mock_client, public_key_pem): + """Test that RSA encryption fetches the public key before encrypting""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', + content_type="application/json", + encryption_scheme="RSA", + ) + + # Mock the public key fetch to return the service's public key + mock_client._fetch_service_public_key = MagicMock(return_value=public_key_pem) + mock_client._data_plane_manager.outgoing_message_data_handler.return_value = ( + b"encrypted_payload" + ) + + # Call the send method + IntersectClient._send_userspace_message(mock_client, params) + + # Verify that public key was fetched + mock_client._fetch_service_public_key.assert_called_once_with("test.org-fac.sys-service") + +def test_send_message_rsa_encryption_payload_structure(mock_client, public_key_pem): + """Test that encrypted payload has the expected structure""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"message": "hello", "value": 42}', + content_type="application/json", + encryption_scheme="RSA", + ) + + mock_client._fetch_service_public_key = MagicMock(return_value=public_key_pem) + + captured_data = None + def capture_handler(data, content_type, handler): + nonlocal captured_data + captured_data = data + return b"encrypted_to_broker" + + mock_client._data_plane_manager.outgoing_message_data_handler = capture_handler + + # Call the send method + IntersectClient._send_userspace_message(mock_client, params) + + # Verify the payload structure + assert isinstance(captured_data, IntersectEncryptedPayload) + assert captured_data.key + assert captured_data.initial_vector + assert captured_data.data + +def test_send_message_rsa_encryption_fails_without_public_key(mock_client): + """Test that sending fails if public key cannot be fetched""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', + content_type="application/json", + encryption_scheme="RSA", + ) + + # Mock the public key fetch to return None (failure) + mock_client._fetch_service_public_key = MagicMock(return_value=None) + + # Call the send method + IntersectClient._send_userspace_message(mock_client, params) + + # Verify that outgoing_message_data_handler was NOT called (message sending failed) + assert not mock_client._data_plane_manager.outgoing_message_data_handler.called + +def test_receive_message_encryption_scheme_none_skips_decryption(mock_client): + """Test that encryption_scheme='NONE' skips decryption for incoming messages""" + # Create message headers for an unencrypted message + headers = create_userspace_message_headers( + source="test.org-fac.sys-service", + destination=mock_client._hierarchy.hierarchy_string('.'), + data_handler=IntersectDataHandler.MESSAGE, + operation_id="test_op", + campaign_id=uuid4(), + request_id=uuid4(), + encryption_scheme="NONE", + ) + + payload = b'{"test": "data"}' + mock_client._data_plane_manager.incoming_message_data_handler.return_value = payload + mock_client._user_callback = MagicMock() + + # Call the handle method + IntersectClient._handle_userspace_message( + mock_client, payload, "application/json", headers + ) + + # Verify that incoming_message_data_handler was called + assert mock_client._data_plane_manager.incoming_message_data_handler.called + +def test_receive_message_rsa_decryption_uses_private_key(mock_client, rsa_keypair): + """Test that RSA decryption uses the client's private key for incoming messages""" + private_key, _ = rsa_keypair + mock_client._private_key = private_key + + # Create a simple message to encrypt + # The client expects a wrapper model with 'data' field containing the JSON string + test_message_json = '{"data": "{\\"result\\": \\"data from service\\"}"}' + key_payload = IntersectEncryptionPublicKey( + public_key=private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + ) + + encrypted_payload = intersect_payload_encrypt( + key_payload=key_payload, + unencrypted_model=test_message_json, + ) + + # Create message headers for an encrypted message + headers = create_userspace_message_headers( + source="test.org-fac.sys-service", + destination=mock_client._hierarchy.hierarchy_string('.'), + data_handler=IntersectDataHandler.MESSAGE, + operation_id="test_op", + campaign_id=uuid4(), + request_id=uuid4(), + encryption_scheme="RSA", + ) + + # Mock the incoming handler to return the encrypted payload + mock_client._data_plane_manager.incoming_message_data_handler.return_value = ( + encrypted_payload + ) + mock_client._user_callback = MagicMock() + + # Call the handle method + IntersectClient._handle_userspace_message( + mock_client, b"ignored_payload", "application/json", headers + ) + + # Verify that incoming_message_data_handler was called + assert mock_client._data_plane_manager.incoming_message_data_handler.called + +def test_receive_public_key_response_stores_in_cache(mock_client): + """Test that public key responses are cached for use in key fetching""" + request_id = uuid4() + public_key_data = {"public_key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n"} + + # Create message headers for a public key response + headers = create_userspace_message_headers( + source="test.org-fac.sys-service", + destination=mock_client._hierarchy.hierarchy_string('.'), + data_handler=IntersectDataHandler.MESSAGE, + operation_id="intersect_sdk.get_public_key", + campaign_id=uuid4(), + request_id=request_id, + encryption_scheme="NONE", + ) + + payload = b'{"public_key": "test_key"}' + mock_client._data_plane_manager.incoming_message_data_handler.return_value = public_key_data + + # Call the handle method + IntersectClient._handle_userspace_message( + mock_client, payload, "application/json", headers + ) + + # Verify that the response was cached using message_id as key + assert hasattr(mock_client, '_public_key_responses') + message_id = headers['message_id'] # Extract from the headers dict + assert message_id in mock_client._public_key_responses + assert mock_client._public_key_responses[message_id] == public_key_data + +def test_send_message_rsa_encryption_roundtrip(mock_client, rsa_keypair, public_key_pem): + """Test that encrypted message can be decrypted""" + private_key, _ = rsa_keypair + class LocalMessage(BaseModel): + text: str + + original_message = LocalMessage(text="Test message from client") + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload=original_message, + content_type="application/json", + encryption_scheme="RSA", + ) + + mock_client._fetch_service_public_key = MagicMock(return_value=public_key_pem) + + captured_data = None + def capture_handler(data, content_type, handler): + nonlocal captured_data + captured_data = data + return b"encrypted_to_broker" + + mock_client._data_plane_manager.outgoing_message_data_handler = capture_handler + + # Send the message (which encrypts it) + IntersectClient._send_userspace_message(mock_client, params) + + # Decrypt it to verify correctness + decrypted = intersect_payload_decrypt( + rsa_private_key=private_key, + encrypted_payload=captured_data, + model=LocalMessage, + ) + + # The decrypted payload should match the original + assert decrypted.model.text == original_message.text diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py new file mode 100644 index 0000000..2337e65 --- /dev/null +++ b/tests/unit/test_encryption.py @@ -0,0 +1,396 @@ +""" +Unit tests for intersect_sdk._internal.encryption module +Tests cover AESCipher, client encryption, and service decryption functionality +""" +import base64 +import json +import os +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from pydantic import BaseModel + +from intersect_sdk._internal.encryption import ( + AESCipher, + intersect_payload_encrypt, + intersect_payload_decrypt, +) +from intersect_sdk._internal.encryption.models import ( + IntersectEncryptedPayload, + IntersectEncryptionPublicKey, + IntersectDecryptedPayload, +) + + +# Sample Models for Testing +class SampleModel(BaseModel): + """Simple sample model for encryption/decryption""" + message: str + value: int + + +class ComplexSampleModel(BaseModel): + """More complex sample model""" + name: str + data: dict + items: list + + +# Fixtures +@pytest.fixture +def aes_key(): + """Generate a 256-bit AES key""" + return os.urandom(32) + + +@pytest.fixture +def aes_iv(): + """Generate a 128-bit initialization vector""" + return os.urandom(16) + + +@pytest.fixture +def rsa_keypair(): + """Generate RSA key pair for testing""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + public_key = private_key.public_key() + return private_key, public_key + + +@pytest.fixture +def public_key_pem(rsa_keypair): + """Get PEM encoded public key""" + _, public_key = rsa_keypair + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + return public_pem + + +# AESCipher Tests +def test_cipher_initialization_valid_key(aes_key, aes_iv): + """Test that AESCipher initializes with valid 256-bit key""" + cipher = AESCipher(aes_key, aes_iv) + assert cipher is not None + assert cipher._key == aes_key + assert cipher._initial_vector == aes_iv + +def test_cipher_initialization_invalid_key_size(aes_iv): + """Test that AESCipher raises ValueError for invalid key size""" + invalid_key = os.urandom(16) # 128-bit instead of 256-bit + with pytest.raises(ValueError, match="Invalid key size"): + AESCipher(invalid_key, aes_iv) + +def test_cipher_encrypt_decrypt_roundtrip(aes_key, aes_iv): + """Test encryption and decryption roundtrip""" + cipher = AESCipher(aes_key, aes_iv) + plaintext = b"Hello, World!" + + encrypted = cipher.encrypt(plaintext) + decrypted = cipher.decrypt(encrypted) + + assert decrypted == plaintext + +def test_cipher_encrypt_returns_base64(aes_key, aes_iv): + """Test that encrypt returns base64 encoded data""" + cipher = AESCipher(aes_key, aes_iv) + plaintext = b"test data" + encrypted = cipher.encrypt(plaintext) + + # Should be valid base64 + decoded = base64.b64decode(encrypted) + assert decoded != plaintext # Should be encrypted + +def test_cipher_decrypt_accepts_base64(aes_key, aes_iv): + """Test that decrypt accepts base64 encoded data""" + cipher = AESCipher(aes_key, aes_iv) + plaintext = b"test data" + encrypted = cipher.encrypt(plaintext) + assert isinstance(encrypted, bytes) + + decrypted = cipher.decrypt(encrypted) + assert decrypted == plaintext + +def test_cipher_encrypt_empty_plaintext(aes_key, aes_iv): + """Test encryption of empty plaintext""" + cipher = AESCipher(aes_key, aes_iv) + plaintext = b"" + + encrypted = cipher.encrypt(plaintext) + decrypted = cipher.decrypt(encrypted) + + assert decrypted == plaintext + +def test_cipher_encrypt_long_plaintext(aes_key, aes_iv): + """Test encryption of longer plaintext""" + cipher = AESCipher(aes_key, aes_iv) + plaintext = b"a" * 10000 + + encrypted = cipher.encrypt(plaintext) + decrypted = cipher.decrypt(encrypted) + + assert decrypted == plaintext + +def test_cipher_different_keys_different_results(aes_iv): + """Test that different keys produce different ciphertexts""" + key1 = os.urandom(32) + key2 = os.urandom(32) + + cipher1 = AESCipher(key1, aes_iv) + cipher2 = AESCipher(key2, aes_iv) + + plaintext = b"test data" + encrypted1 = cipher1.encrypt(plaintext) + encrypted2 = cipher2.encrypt(plaintext) + + assert encrypted1 != encrypted2 + +def test_cipher_same_plaintext_different_ivs(aes_key): + """Test that same plaintext with different IVs produces different ciphertexts""" + iv1 = os.urandom(16) + iv2 = os.urandom(16) + + cipher1 = AESCipher(aes_key, iv1) + cipher2 = AESCipher(aes_key, iv2) + + plaintext = b"test data" + encrypted1 = cipher1.encrypt(plaintext) + encrypted2 = cipher2.encrypt(plaintext) + + assert encrypted1 != encrypted2 + + +# Client Encryption Tests +def test_client_encryption_basic(public_key_pem): + """Test basic client encryption""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + model_data = SampleModel(message="hello", value=42) + unencrypted_json = model_data.model_dump_json() + + result = intersect_payload_encrypt(key_payload, unencrypted_json) + + assert isinstance(result, IntersectEncryptedPayload) + assert result.key # Encrypted AES key + assert result.initial_vector # IV + assert result.data # Encrypted data + +def test_client_encryption_produces_base64(public_key_pem): + """Test that client encryption produces base64 encoded outputs""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + unencrypted_json = '{"test": "data"}' + + result = intersect_payload_encrypt(key_payload, unencrypted_json) + + # All fields should be valid base64 + base64.b64decode(result.key.encode()) + base64.b64decode(result.initial_vector.encode()) + base64.b64decode(result.data.encode()) + +def test_client_encryption_different_plaintext(public_key_pem): + """Test that different plaintext produces different ciphertexts""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + + result1 = intersect_payload_encrypt(key_payload, '{"text": "first"}') + result2 = intersect_payload_encrypt(key_payload, '{"text": "second"}') + + # Data should be different + assert result1.data != result2.data + +def test_client_encryption_same_plaintext_different_outputs(public_key_pem): + """Test that encrypting same plaintext produces different outputs (due to random AES key/IV)""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + plaintext = '{"test": "data"}' + + result1 = intersect_payload_encrypt(key_payload, plaintext) + result2 = intersect_payload_encrypt(key_payload, plaintext) + + # Due to random key and IV generation, outputs should differ + assert result1.key != result2.key + assert result1.initial_vector != result2.initial_vector + +def test_client_encryption_complex_model(public_key_pem): + """Test client encryption with complex model""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + model_data = ComplexSampleModel( + name="test", + data={"nested": {"key": "value"}}, + items=[1, 2, 3], + ) + unencrypted_json = model_data.model_dump_json() + + result = intersect_payload_encrypt(key_payload, unencrypted_json) + + assert isinstance(result, IntersectEncryptedPayload) + assert result.key and result.initial_vector and result.data + +def test_client_encryption_large_payload(public_key_pem): + """Test client encryption with large payload""" + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + large_data = {"data": "x" * 100000} + unencrypted_json = json.dumps(large_data) + + result = intersect_payload_encrypt(key_payload, unencrypted_json) + + assert isinstance(result, IntersectEncryptedPayload) + assert result.key and result.initial_vector and result.data + + +# Service Decryption Tests +def test_service_decryption_basic(rsa_keypair, public_key_pem): + """Test basic service decryption""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + model_data = SampleModel(message="hello", value=42) + unencrypted_json = model_data.model_dump_json() + + # Encrypt with client + encrypted = intersect_payload_encrypt(key_payload, unencrypted_json) + + # Decrypt with service + result = intersect_payload_decrypt(private_key, encrypted, SampleModel) + + assert isinstance(result, IntersectDecryptedPayload) + assert isinstance(result.model, SampleModel) + assert result.model.message == "hello" + assert result.model.value == 42 + +def test_service_decryption_returns_aes_key_and_iv(rsa_keypair, public_key_pem): + """Test that service decryption returns AES key and IV""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + unencrypted_json = '{"message": "test", "value": 10}' + + encrypted = intersect_payload_encrypt(key_payload, unencrypted_json) + result = intersect_payload_decrypt(private_key, encrypted, SampleModel) + + assert result.aes_key is not None + assert result.aes_initialization_vector is not None + assert isinstance(result.aes_key, bytes) + assert isinstance(result.aes_initialization_vector, bytes) + +def test_service_decryption_complex_model(rsa_keypair, public_key_pem): + """Test service decryption with complex model""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + model_data = ComplexSampleModel( + name="complex", + data={"key": "value", "nested": {"deep": "data"}}, + items=[1, 2, 3, 4, 5], + ) + unencrypted_json = model_data.model_dump_json() + + encrypted = intersect_payload_encrypt(key_payload, unencrypted_json) + result = intersect_payload_decrypt(private_key, encrypted, ComplexSampleModel) + + assert isinstance(result.model, ComplexSampleModel) + assert result.model.name == "complex" + assert result.model.data["key"] == "value" + assert result.model.items == [1, 2, 3, 4, 5] + +def test_service_decryption_wrong_private_key_fails(rsa_keypair): + """Test that decryption fails with wrong private key""" + private_key1, public_key1 = rsa_keypair + private_key2 = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + + # Get PEM encoded public key from the matching keypair + public_pem = public_key1.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + key_payload = IntersectEncryptionPublicKey(public_key=public_pem) + unencrypted_json = '{"message": "test", "value": 10}' + + # Encrypt with private_key1's public key + encrypted = intersect_payload_encrypt(key_payload, unencrypted_json) + + # Verify decryption works with correct private key + result = intersect_payload_decrypt(private_key1, encrypted, SampleModel) + assert result.model.message == "test" + assert result.model.value == 10 + + # Try to decrypt with private_key2 (should fail) + with pytest.raises(Exception): # Will raise decryption error + intersect_payload_decrypt(private_key2, encrypted, SampleModel) + +def test_service_decryption_corrupted_payload_fails(rsa_keypair, public_key_pem): + """Test that decryption fails with corrupted payload""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + unencrypted_json = '{"message": "test", "value": 10}' + + encrypted = intersect_payload_encrypt(key_payload, unencrypted_json) + + # Corrupt the encrypted data + corrupted = IntersectEncryptedPayload( + key=encrypted.key, + initial_vector=encrypted.initial_vector, + data=base64.b64encode(b"corrupted data").decode(), + ) + + with pytest.raises(Exception): # Will raise decryption/validation error + intersect_payload_decrypt(private_key, corrupted, SampleModel) + +# Integration Tests +def test_full_roundtrip_simple_model(rsa_keypair, public_key_pem): + """Test full encryption/decryption roundtrip with simple model""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + + original = SampleModel(message="test message", value=123) + original_json = original.model_dump_json() + + # Encrypt + encrypted = intersect_payload_encrypt(key_payload, original_json) + + # Decrypt + decrypted = intersect_payload_decrypt(private_key, encrypted, SampleModel) + + assert decrypted.model.message == original.message + assert decrypted.model.value == original.value + +def test_full_roundtrip_complex_model(rsa_keypair, public_key_pem): + """Test full encryption/decryption roundtrip with complex model""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + + original = ComplexSampleModel( + name="integration test", + data={"level1": {"level2": {"level3": "value"}}}, + items=[1, "two", 3.0, {"four": 4}], + ) + original_json = original.model_dump_json() + + encrypted = intersect_payload_encrypt(key_payload, original_json) + decrypted = intersect_payload_decrypt(private_key, encrypted, ComplexSampleModel) + + assert decrypted.model == original + +def test_multiple_messages_same_key(rsa_keypair, public_key_pem): + """Test encrypting and decrypting multiple messages with same RSA key""" + private_key, _ = rsa_keypair + key_payload = IntersectEncryptionPublicKey(public_key=public_key_pem) + + messages = [ + SampleModel(message="first", value=1), + SampleModel(message="second", value=2), + SampleModel(message="third", value=3), + ] + + results = [] + for msg in messages: + encrypted = intersect_payload_encrypt(key_payload, msg.model_dump_json()) + decrypted = intersect_payload_decrypt(private_key, encrypted, SampleModel) + results.append(decrypted.model) + + for original, result in zip(messages, results): + assert original == result diff --git a/tests/unit/test_service_rsa_encryption.py b/tests/unit/test_service_rsa_encryption.py new file mode 100644 index 0000000..de4eeb0 --- /dev/null +++ b/tests/unit/test_service_rsa_encryption.py @@ -0,0 +1,346 @@ +""" +Unit tests for RSA encryption in IntersectService._send_client_message + +Tests cover the service-to-service RSA encryption flow where a service: +1. Fetches the public key from the destination service +2. Encrypts the payload using that public key +3. Sends the encrypted payload +""" +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from pydantic import BaseModel +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +from intersect_sdk._internal.encryption import intersect_payload_decrypt +from intersect_sdk._internal.encryption.models import IntersectEncryptedPayload +from intersect_sdk.shared_callback_definitions import IntersectDirectMessageParams +from intersect_sdk.service import IntersectService + + +@pytest.fixture +def rsa_keypair(): + """Generate RSA key pair for testing""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + public_key = private_key.public_key() + return private_key, public_key + + +@pytest.fixture +def public_key_pem(rsa_keypair): + """Get PEM encoded public key""" + _, public_key = rsa_keypair + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + return public_pem + + +@pytest.fixture +def mock_service(): + """Create a mock IntersectService for testing""" + mock_svc = MagicMock(spec=IntersectService) + mock_svc._hierarchy = MagicMock() + mock_svc._hierarchy.hierarchy_string.return_value = "test.org-fac.sys-service" + mock_svc._campaign_id = uuid4() + mock_svc._private_key = MagicMock() + mock_svc._external_requests = {} + mock_svc._external_request_ctr = 0 + + # Mock data plane manager + mock_svc._data_plane_manager = MagicMock() + + # Mock control plane manager + mock_svc._control_plane_manager = MagicMock() + + return mock_svc + + + +def test_encryption_scheme_none_skips_encryption(mock_service): + """Test that encryption_scheme='NONE' skips the encryption logic""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-other", + operation="test_op", + payload={"test": "data"}, + encryption_scheme="NONE", + ) + + mock_service._data_plane_manager.outgoing_message_data_handler.return_value = ( + b"test_payload" + ) + + # Call the actual method with mocked service + result = IntersectService._send_client_message(mock_service, uuid4(), params) + + assert result is True + # Verify that publish_message was called + assert mock_service._control_plane_manager.publish_message.called + +def test_rsa_encryption_fetches_public_key(mock_service, public_key_pem): + """Test that RSA encryption requests the public key from destination service""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', # JSON string + content_type="application/json", + encryption_scheme="RSA", + ) + + # Mock the external request mechanism + public_key_request_id = uuid4() + requests_created = [] + + def mock_create_external_request(request, response_handler=None, timeout=None): + """Mock external request creation""" + requests_created.append(request) + if request.operation == "intersect_sdk.get_public_key": + return public_key_request_id + return uuid4() + + def mock_get_external_request(req_id): + """Mock getting external request status""" + if req_id == public_key_request_id: + # Return a mock request with received state and public key response + mock_extreq = MagicMock() + mock_extreq.request_state = "received" + mock_extreq.response_payload = { + "public_key": public_key_pem + } + return mock_extreq + return None + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + mock_service._data_plane_manager.outgoing_message_data_handler.return_value = ( + b"encrypted_payload" + ) + + # Call the method + result = IntersectService._send_client_message(mock_service, uuid4(), params) + + assert result is True + # Verify that the public key request was created + assert len(requests_created) >= 1 + assert any(r.operation == "intersect_sdk.get_public_key" for r in requests_created) + +def test_rsa_encryption_timeout_returns_false(mock_service): + """Test that RSA encryption returns False when public key fetch times out""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload={"test": "data"}, + content_type="application/json", + encryption_scheme="RSA", + ) + + public_key_request_id = uuid4() + + def mock_create_external_request(request, response_handler=None, timeout=None): + return public_key_request_id + + def mock_get_external_request(req_id): + """Return a request that never receives a response""" + if req_id == public_key_request_id: + mock_extreq = MagicMock() + mock_extreq.request_state = "sent" # Never transitions to 'received' + return mock_extreq + return None + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + + # Patch time.time to control the timeout + # We need to return enough values since time.time is called multiple times + with patch("intersect_sdk.service.time.time") as mock_time: + # Return values that will trigger the timeout condition + # First call at 0, then subsequent calls will progress past 30 + time_values = [0] + [31] * 100 # Start at 0, then always return 31 (exceeds timeout) + mock_time.side_effect = time_values + + result = IntersectService._send_client_message(mock_service, uuid4(), params) + + assert result is False + # Verify publish was NOT called due to timeout + assert not mock_service._control_plane_manager.publish_message.called + +def test_rsa_encryption_payload_structure(mock_service, public_key_pem): + """Test that the encrypted payload has the expected structure""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"message": "hello", "value": 42}', + content_type="application/json", + encryption_scheme="RSA", + ) + + public_key_request_id = uuid4() + received_encrypted_payload = None + + def mock_create_external_request(request, response_handler=None, timeout=None): + return public_key_request_id + + def mock_get_external_request(req_id): + if req_id == public_key_request_id: + mock_extreq = MagicMock() + mock_extreq.request_state = "received" + mock_extreq.response_payload = { + "public_key": public_key_pem + } + return mock_extreq + return None + + def mock_outgoing_handler(data, content_type, handler): + nonlocal received_encrypted_payload + received_encrypted_payload = data + return b"encrypted_data_to_broker" + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + mock_service._data_plane_manager.outgoing_message_data_handler = mock_outgoing_handler + + result = IntersectService._send_client_message(mock_service, uuid4(), params) + + assert result is True + # Verify that the data passed to outgoing_handler is an IntersectEncryptedPayload + assert isinstance(received_encrypted_payload, IntersectEncryptedPayload) + assert received_encrypted_payload.key # Should have encrypted AES key + assert received_encrypted_payload.initial_vector # Should have IV + assert received_encrypted_payload.data # Should have encrypted data + +def test_rsa_encryption_roundtrip(mock_service, public_key_pem, rsa_keypair): + """Test that encrypted payload can be decrypted with the correct private key""" + from pydantic import BaseModel + + private_key, _ = rsa_keypair + + # Create a simple model to use in roundtrip + class TestMessage(BaseModel): + text: str + + original_message = TestMessage(text="Test message for encryption") + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload=original_message, # JSON serializable model + content_type="application/json", + encryption_scheme="RSA", + ) + + public_key_request_id = uuid4() + received_encrypted_payload = None + + def mock_create_external_request(request, response_handler=None, timeout=None): + return public_key_request_id + + def mock_get_external_request(req_id): + if req_id == public_key_request_id: + mock_extreq = MagicMock() + mock_extreq.request_state = "received" + mock_extreq.response_payload = { + "public_key": public_key_pem + } + return mock_extreq + return None + + def mock_outgoing_handler(data, content_type, handler): + nonlocal received_encrypted_payload + received_encrypted_payload = data + return b"encrypted_data_to_broker" + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + mock_service._data_plane_manager.outgoing_message_data_handler = mock_outgoing_handler + + # Send the message (which encrypts it) + result = IntersectService._send_client_message(mock_service, uuid4(), params) + assert result is True + + # Now decrypt it to verify correctness + decrypted = intersect_payload_decrypt( + rsa_private_key=private_key, + encrypted_payload=received_encrypted_payload, + model=TestMessage, + ) + + # The decrypted payload should match the original + assert decrypted.model.text == original_message.text + +def test_rsa_encryption_with_dict_response(mock_service, public_key_pem): + """Test that RSA encryption handles dict response from get_public_key""" + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', + content_type="application/json", + encryption_scheme="RSA", + ) + + public_key_request_id = uuid4() + + def mock_create_external_request(request, response_handler=None, timeout=None): + return public_key_request_id + + def mock_get_external_request(req_id): + if req_id == public_key_request_id: + mock_extreq = MagicMock() + mock_extreq.request_state = "received" + # Return response as a dict (as it would come from JSON deserialization) + mock_extreq.response_payload = { + "public_key": public_key_pem + } + return mock_extreq + return None + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + mock_service._data_plane_manager.outgoing_message_data_handler.return_value = ( + b"encrypted_payload" + ) + + result = IntersectService._send_client_message(mock_service, uuid4(), params) + + assert result is True + # Verify that publish_message was called (indicating successful encryption) + assert mock_service._control_plane_manager.publish_message.called + +def test_rsa_encryption_invalid_public_key_response(mock_service): + """Test error handling when public key response is invalid""" + from pydantic_core import ValidationError + + params = IntersectDirectMessageParams( + destination="test.org-fac.sys-service", + operation="test_op", + payload='{"test": "data"}', + content_type="application/json", + encryption_scheme="RSA", + ) + + public_key_request_id = uuid4() + + def mock_create_external_request(request, response_handler=None, timeout=None): + return public_key_request_id + + def mock_get_external_request(req_id): + if req_id == public_key_request_id: + mock_extreq = MagicMock() + mock_extreq.request_state = "received" + # Return invalid response (missing public_key field) + mock_extreq.response_payload = {"invalid": "response"} + return mock_extreq + return None + + mock_service.create_external_request = mock_create_external_request + mock_service._get_external_request = mock_get_external_request + + # Should raise a ValidationError due to invalid public key response + with pytest.raises((ValidationError, KeyError, TypeError, AttributeError)): + IntersectService._send_client_message(mock_service, uuid4(), params) diff --git a/tests/unit/test_userspace_message_headers.py b/tests/unit/test_userspace_message_headers.py index 6bfa47c..138d352 100644 --- a/tests/unit/test_userspace_message_headers.py +++ b/tests/unit/test_userspace_message_headers.py @@ -115,6 +115,7 @@ def test_create_userspace_message() -> None: data_handler=IntersectDataHandler.MESSAGE, request_id=uuid.uuid4(), campaign_id=uuid.uuid4(), + encryption_scheme='NONE', ) # make sure all values are serialized as strings, this is necessary for some protocols i.e. MQTT5 Properties