From 8f71634398600e69c9554325afca741bedda9709 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 5 Aug 2026 10:55:58 +0100 Subject: [PATCH 01/24] Added client-side property encryption protocol objects and simple round-trip stub test --- nutkit/frontend/driver.py | 40 +++++++++- nutkit/protocol/feature.py | 4 + nutkit/protocol/requests.py | 75 +++++++++++++++++++ nutkit/protocol/responses.py | 36 +++++++++ tests/stub/property_encryption/__init__.py | 0 .../test_property_encryption.py | 39 ++++++++++ 6 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/stub/property_encryption/__init__.py create mode 100644 tests/stub/property_encryption/test_property_encryption.py diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index 095cdd779..2ac601e46 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -24,7 +24,8 @@ def __init__(self, backend, uri, auth_token, user_agent=None, notifications_disabled_categories=None, telemetry_disabled=None, client_certificate=None, - disable_auto_commit_retries=None): + disable_auto_commit_retries=None, + property_encryption_profiles=None): self._backend = backend self._resolver_fn = resolver_fn self._domain_name_resolver_fn = domain_name_resolver_fn @@ -54,6 +55,12 @@ def __init__(self, backend, uri, auth_token, user_agent=None, else: client_certificate_provider_id_ = client_certificate.id + property_encryption_profiles_ = None + if property_encryption_profiles is not None: + property_encryption_profiles_ = [ + {"name": name} for name in property_encryption_profiles + ] + req = protocol.NewDriver( uri, self._auth_token, auth_token_manager_id, userAgent=user_agent, resolverRegistered=resolver_fn is not None, @@ -71,6 +78,7 @@ def __init__(self, backend, uri, auth_token, user_agent=None, client_certificate=client_certificate_, client_certificate_provider_id=client_certificate_provider_id_, disable_auto_commit_retries=disable_auto_commit_retries, + property_encryption_profiles=property_encryption_profiles_, ) res = backend.send_and_receive(req) if not isinstance(res, protocol.Driver): @@ -188,6 +196,36 @@ def is_encrypted(self): raise Exception(f"Should be DriverIsEncrypted but was {res}") return res.encrypted + def encrypt_to_bytes(self, value, *, aad=None, profile_name=None, + key_alias=None, key_id=None): + req = protocol.EncryptToBytes( + self._driver.id, value, aad=aad, profile_name=profile_name, + key_alias=key_alias, key_id=key_id + ) + res = self.send_and_receive(req, allow_resolution=False) + if not isinstance(res, protocol.EncryptedValue): + raise Exception(f"Should be EncryptedValue but was: {res}") + return res.encrypted_bytes + + def decrypt(self, value, *, aad=None, use_persisted_aad=False): + req = protocol.Decrypt( + self._driver.id, value, aad=aad, + use_persisted_aad=use_persisted_aad + ) + res = self.send_and_receive(req, allow_resolution=False) + if not isinstance(res, protocol.DecryptedValue): + raise Exception(f"Should be DecryptedValue but was: {res}") + return res.decrypted_value + + def create_encapsulated_key(self, alias, *, profile_name=None): + req = protocol.CreateEncapsulatedKey( + self._driver.id, alias, profile_name=profile_name + ) + res = self.send_and_receive(req, allow_resolution=False) + if not isinstance(res, protocol.EncapsulatedKey): + raise Exception(f"Should be EncapsulatedKey but was: {res}") + return res + def close(self): req = protocol.DriverClose(self._driver.id) res = self.send_and_receive(req, allow_resolution=False) diff --git a/nutkit/protocol/feature.py b/nutkit/protocol/feature.py index f836a3a17..504aea581 100644 --- a/nutkit/protocol/feature.py +++ b/nutkit/protocol/feature.py @@ -44,6 +44,10 @@ class Feature(Enum): API_DRIVER_SUPPORTS_SESSION_AUTH = "Feature:API:Driver.SupportsSessionAuth" # The driver supports connection liveness check. API_LIVENESS_CHECK = "Feature:API:Liveness.Check" + # The driver offers a public API for client-side property encryption: + # encrypting and decrypting individual property values, and managing + # encapsulated data encryption keys. + API_PROPERTY_ENCRYPTION = "Feature:API:PropertyEncryption" # The driver offers a method for the result to return all records as a list # or array. This method should exhaust the result. API_RESULT_LIST = "Feature:API:Result.List" diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index 7583f7148..d7e17283c 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -79,6 +79,7 @@ def __init__( telemetry_disabled=None, client_certificate=None, client_certificate_provider_id=None, disable_auto_commit_retries=None, + property_encryption_profiles=None, ): # Neo4j URI to connect to self.uri = uri @@ -110,6 +111,8 @@ def __init__( self.telemetryDisabled = telemetry_disabled if disable_auto_commit_retries is not None: self.disableAutoCommitRetries = disable_auto_commit_retries + if property_encryption_profiles is not None: + self.propertyEncryptionProfiles = property_encryption_profiles # (bool) whether to enable or disable encryption # field missing in message: use driver default (should be False) if encrypted is not None: @@ -891,3 +894,75 @@ class FakeTimeUninstall: """ pass + + +class EncryptToBytes: + """ + Request to encrypt a value using client-side property encryption. + + The backend should respond with an EncryptedValue or an Error response. + + :param driver_id: The id of the driver to encrypt with. + :param value: The property value to encrypt. + :param aad: The additional authenticated data (AAD) to bind to the + ciphertext, or None for no AAD. + :param profile_name: The name of the encryption profile to use, or None + to use the sole configured profile. + :param key_alias: The alias of the data encryption key to encrypt with. + Mutually exclusive with key_id; exactly one of the two must be set. + :param key_id: The repository-assigned id of the data encryption key to + encrypt with. Mutually exclusive with key_alias; exactly one of the + two must be set. + """ + + def __init__(self, driver_id, value, aad=None, profile_name=None, + key_alias=None, key_id=None): + self.driverId = driver_id + self.value = value + self.aad = aad + self.profileName = profile_name + self.keyAlias = key_alias + self.keyId = key_id + + +class Decrypt: + """ + Request to decrypt a value using client-side property encryption. + + The backend should respond with a DecryptedValue or an Error response. + + :param driver_id: The id of the driver to decrypt with. + :param value: The encrypted value to decrypt, as returned by + EncryptToBytes. + :param aad: The additional authenticated data (AAD) to reproduce, or + None to use the AAD persisted alongside the encrypted value. + Mutually exclusive with use_persisted_aad; exactly one of the two + must be set. + :param use_persisted_aad: Whether to use the AAD persisted alongside the + encrypted value. Mutually exclusive with aad; exactly one of the two + must be set. + """ + + def __init__(self, driver_id, value, aad=None, use_persisted_aad=False): + self.driverId = driver_id + self.value = value + self.aad = aad + self.usePersistedAad = use_persisted_aad + + +class CreateEncapsulatedKey: + """ + Request to create a new encapsulated data encryption key. + + The backend should respond with an EncapsulatedKey or an Error response. + + :param driver_id: The id of the driver to create the key with. + :param alias: The alias to bind to the new key. + :param profile_name: The name of the encryption profile to create the + key for, or None to use the sole configured profile. + """ + + def __init__(self, driver_id, alias, profile_name=None): + self.driverId = driver_id + self.alias = alias + self.profileName = profile_name diff --git a/nutkit/protocol/responses.py b/nutkit/protocol/responses.py index 95a9ecd3c..d9ac20980 100644 --- a/nutkit/protocol/responses.py +++ b/nutkit/protocol/responses.py @@ -343,6 +343,42 @@ def __init__(self, id): self.id = id +class EncryptedValue: + """ + The result of encrypting a value with client-side property encryption. + + Sent in response to an EncryptToBytes request. + """ + + def __init__(self, encrypted_bytes): + self.encrypted_bytes = encrypted_bytes + + +class DecryptedValue: + """ + The result of decrypting a value with client-side property encryption. + + Sent in response to a Decrypt request. + """ + + def __init__(self, decrypted_value): + self.decrypted_value = decrypted_value + + +class EncapsulatedKey: + """ + An encapsulated data encryption key. + + Sent in response to a CreateEncapsulatedKey request. + """ + + def __init__(self, id, alias, encapsulated_bytes, metadata): + self.id = id + self.alias = alias + self.encapsulated_bytes = encapsulated_bytes + self.metadata = metadata + + class Result: """Represents a result instance on the backend.""" diff --git a/tests/stub/property_encryption/__init__.py b/tests/stub/property_encryption/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py new file mode 100644 index 000000000..6d2340807 --- /dev/null +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -0,0 +1,39 @@ +import nutkit.protocol as types +from nutkit.frontend import Driver +from tests.shared import TestkitTestCase +from tests.stub.shared import StubServer + + +class TestPropertyEncryption(TestkitTestCase): + required_features = (types.Feature.API_PROPERTY_ENCRYPTION,) + + def setUp(self): + super().setUp() + self._server = StubServer(9020) + self._driver = None + + def tearDown(self): + if self._driver: + self._driver.close() + return super().tearDown() + + def _new_driver(self, profiles=("default",)): + auth = types.AuthorizationToken("basic", principal="neo4j", + credentials="pass") + uri = "bolt://%s" % self._server.address + self._driver = Driver( + self._backend, uri, auth, + property_encryption_profiles=list(profiles) + ) + return self._driver + + def test_encrypts_and_decrypts_hello_world(self): + driver = self._new_driver() + driver.create_encapsulated_key("k1") + + encrypted = driver.encrypt_to_bytes( + types.CypherString("hello world"), key_alias="k1" + ) + decrypted = driver.decrypt(encrypted, use_persisted_aad=True) + + self.assertEqual(decrypted, types.CypherString("hello world")) From 5953e4d422284806821badb3142a1de953dcf310 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 5 Aug 2026 11:22:38 +0100 Subject: [PATCH 02/24] Fix casing --- nutkit/protocol/responses.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nutkit/protocol/responses.py b/nutkit/protocol/responses.py index d9ac20980..47ec424d6 100644 --- a/nutkit/protocol/responses.py +++ b/nutkit/protocol/responses.py @@ -350,8 +350,8 @@ class EncryptedValue: Sent in response to an EncryptToBytes request. """ - def __init__(self, encrypted_bytes): - self.encrypted_bytes = encrypted_bytes + def __init__(self, encryptedBytes): + self.encrypted_bytes = encryptedBytes class DecryptedValue: @@ -361,8 +361,8 @@ class DecryptedValue: Sent in response to a Decrypt request. """ - def __init__(self, decrypted_value): - self.decrypted_value = decrypted_value + def __init__(self, decryptedValue): + self.decrypted_value = decryptedValue class EncapsulatedKey: @@ -372,10 +372,10 @@ class EncapsulatedKey: Sent in response to a CreateEncapsulatedKey request. """ - def __init__(self, id, alias, encapsulated_bytes, metadata): + def __init__(self, id, alias, encapsulatedBytes, metadata): self.id = id self.alias = alias - self.encapsulated_bytes = encapsulated_bytes + self.encapsulated_bytes = encapsulatedBytes self.metadata = metadata From bb101a54f998d76251de045a9bb1d7591281256b Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 5 Aug 2026 12:08:42 +0100 Subject: [PATCH 03/24] Strengthen property encryption stub tests Rename the single round-trip test; add a many-values-in-shuffled-order test that catches order-dependent fakes and type confusion, and a separate test asserting fresh-IV encryption of the same value twice yields different ciphertext. --- .../test_property_encryption.py | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 6d2340807..5f56ffd7c 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -1,3 +1,5 @@ +import random + import nutkit.protocol as types from nutkit.frontend import Driver from tests.shared import TestkitTestCase @@ -27,7 +29,7 @@ def _new_driver(self, profiles=("default",)): ) return self._driver - def test_encrypts_and_decrypts_hello_world(self): + def test_round_trips_a_single_value(self): driver = self._new_driver() driver.create_encapsulated_key("k1") @@ -37,3 +39,48 @@ def test_encrypts_and_decrypts_hello_world(self): decrypted = driver.decrypt(encrypted, use_persisted_aad=True) self.assertEqual(decrypted, types.CypherString("hello world")) + + def test_round_trips_many_values_in_shuffled_order(self): + driver = self._new_driver() + driver.create_encapsulated_key("k1") + + values = [ + types.CypherBool(True), + types.CypherBool(False), + types.CypherInt(0), + types.CypherInt(-1), + types.CypherInt(9223372036854775807), + types.CypherFloat(3.25), + types.CypherString(""), + types.CypherString("a"), + types.CypherString("hello world"), + types.CypherBytes(b""), + types.CypherBytes(b"\x00\x01\x02"), + types.CypherList([types.CypherInt(1), types.CypherInt(2)]) + ] + + vectors = [ + (value, driver.encrypt_to_bytes(value, key_alias="k1")) + for value in values + ] + + order = list(range(len(vectors))) + random.shuffle(order) + + for i in order: + original, encrypted = vectors[i] + decrypted = driver.decrypt(encrypted, use_persisted_aad=True) + self.assertEqual( + decrypted, original, + f"vector {i} ({original!r}) round-tripped to {decrypted!r}" + ) + + def test_encrypting_the_same_value_twice_yields_different_ciphertext(self): + driver = self._new_driver() + driver.create_encapsulated_key("k1") + + value = types.CypherString("hello world") + first = driver.encrypt_to_bytes(value, key_alias="k1") + second = driver.encrypt_to_bytes(value, key_alias="k1") + + self.assertNotEqual(first, second) From 4639707299d02fcc557f6ea3ae128aba8497a21e Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 5 Aug 2026 15:31:50 +0100 Subject: [PATCH 04/24] Add a stub test for decrypting with the wrong AAD Encrypts with one AAD then decrypts with a different one and expects a DriverError, rounding out the property-encryption error-path coverage alongside the existing round-trip tests. --- .../property_encryption/test_property_encryption.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 5f56ffd7c..d329d692b 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -84,3 +84,16 @@ def test_encrypting_the_same_value_twice_yields_different_ciphertext(self): second = driver.encrypt_to_bytes(value, key_alias="k1") self.assertNotEqual(first, second) + + def test_decrypt_raises_on_wrong_aad(self): + driver = self._new_driver() + driver.create_encapsulated_key("k1") + + encrypted = driver.encrypt_to_bytes( + types.CypherString("aad-bound"), + aad=types.CypherString("row-42"), + key_alias="k1" + ) + + with self.assertRaises(types.DriverError): + driver.decrypt(encrypted, aad=types.CypherString("row-999")) From 436914a819830678c0071f04778ad23b26b15e5f Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 5 Aug 2026 16:56:07 +0100 Subject: [PATCH 05/24] Add more tests, scraping the barrel of the ADR a bit --- .../test_property_encryption.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index d329d692b..a192872f6 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -97,3 +97,35 @@ def test_decrypt_raises_on_wrong_aad(self): with self.assertRaises(types.DriverError): driver.decrypt(encrypted, aad=types.CypherString("row-999")) + + def test_encrypt_raises_on_unknown_alias(self): + driver = self._new_driver() + + with self.assertRaises(types.DriverError): + driver.encrypt_to_bytes( + types.CypherString("hello world"), key_alias="no-such-key" + ) + + def test_encrypt_raises_when_alias_belongs_to_a_different_profile(self): + driver = self._new_driver(profiles=("p1", "p2")) + driver.create_encapsulated_key("k1", profile_name="p1") + + with self.assertRaises(types.DriverError): + driver.encrypt_to_bytes( + types.CypherString("hello world"), + profile_name="p2", key_alias="k1" + ) + + def test_encrypt_raises_on_unknown_key_id(self): + driver = self._new_driver() + + with self.assertRaises(types.DriverError): + driver.encrypt_to_bytes( + types.CypherString("hello world"), key_id="no-such-id" + ) + + def test_key_manager_raises_when_ambiguous_and_no_profile_given(self): + driver = self._new_driver(profiles=("p1", "p2")) + + with self.assertRaises(types.DriverError): + driver.create_encapsulated_key("k1") From 559bc30ac5f3a9e38f17b989c1e43f30880a5656 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 6 Aug 2026 12:43:53 +0100 Subject: [PATCH 06/24] Add cross-driver decrypt-interop check for property encryption New ImportEncapsulatedKey request (seeds a profile's repository with a pre-made key) and a fixedKek profile option, letting a driver decrypt values it never encrypted itself. decrypt_interop_fixtures.py holds one shared FIXTURES-style list, one entry per driver team, each produced by running generate_decrypt_interop_fixture.py against that driver's own backend; test_decrypts_values_produced_by_other_drivers decrypts every entry, proving each backend can read values encrypted by every other driver. Also covers unknown key alias/id and ambiguous-profile error paths, and a fixedKek/ImportEncapsulatedKey round-trip smoke test between two driver instances. --- nutkit/frontend/driver.py | 24 +++++- nutkit/protocol/requests.py | 30 +++++++ .../decrypt_interop_fixtures.py | 57 +++++++++++++ .../generate_decrypt_interop_fixture.py | 82 +++++++++++++++++++ .../test_property_encryption.py | 51 ++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 tests/stub/property_encryption/decrypt_interop_fixtures.py create mode 100644 tests/stub/property_encryption/generate_decrypt_interop_fixture.py diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index 2ac601e46..f04ac2b11 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -58,7 +58,8 @@ def __init__(self, backend, uri, auth_token, user_agent=None, property_encryption_profiles_ = None if property_encryption_profiles is not None: property_encryption_profiles_ = [ - {"name": name} for name in property_encryption_profiles + self._encryption_profile_wire(p) + for p in property_encryption_profiles ] req = protocol.NewDriver( @@ -86,6 +87,16 @@ def __init__(self, backend, uri, auth_token, user_agent=None, self._driver = res self._closed = False + @staticmethod + def _encryption_profile_wire(profile): + if isinstance(profile, str): + return {"name": profile} + wire = {"name": profile["name"]} + fixed_kek = profile.get("fixed_kek") + if fixed_kek is not None: + wire["fixedKek"] = protocol.CypherBytes(fixed_kek) + return wire + def receive(self, timeout=None, hooks=None, *, allow_resolution): while True: res = self._backend.receive(timeout=timeout, hooks=hooks) @@ -226,6 +237,17 @@ def create_encapsulated_key(self, alias, *, profile_name=None): raise Exception(f"Should be EncapsulatedKey but was: {res}") return res + def import_encapsulated_key(self, alias, encapsulation, metadata, *, + profile_name=None): + req = protocol.ImportEncapsulatedKey( + self._driver.id, alias, encapsulation, metadata, + profile_name=profile_name + ) + res = self.send_and_receive(req, allow_resolution=False) + if not isinstance(res, protocol.EncapsulatedKey): + raise Exception(f"Should be EncapsulatedKey but was: {res}") + return res + def close(self): req = protocol.DriverClose(self._driver.id) res = self.send_and_receive(req, allow_resolution=False) diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index d7e17283c..fa48f5ff0 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -966,3 +966,33 @@ def __init__(self, driver_id, alias, profile_name=None): self.driverId = driver_id self.alias = alias self.profileName = profile_name + + +class ImportEncapsulatedKey: + """ + Request to register a pre-existing encapsulated data encryption key. + + Unlike CreateEncapsulatedKey, this does not generate a new key via the + profile's KeyEncapsulationService; it seeds the profile's + EncapsulatedKeyRepository directly with an encapsulation obtained + elsewhere (e.g. a fixture, or a prior CreateEncapsulatedKey response). + + The backend should respond with an EncapsulatedKey or an Error response. + + :param driver_id: The id of the driver to import the key into. + :param alias: The alias to bind to the imported key. + :param encapsulation: The encapsulated (wrapped) data encryption key + bytes. + :param metadata: The key encapsulation service's metadata for the + encapsulation. + :param profile_name: The name of the encryption profile to import the + key into, or None to use the sole configured profile. + """ + + def __init__(self, driver_id, alias, encapsulation, metadata, + profile_name=None): + self.driverId = driver_id + self.alias = alias + self.encapsulation = encapsulation + self.metadata = metadata + self.profileName = profile_name diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py new file mode 100644 index 000000000..628248c5f --- /dev/null +++ b/tests/stub/property_encryption/decrypt_interop_fixtures.py @@ -0,0 +1,57 @@ +""" +Shared cross-driver fixture list for the decrypt-interop check. + +Each entry was produced by one driver team's own implementation, using +generate_decrypt_interop_fixture.py against their own backend. Every driver's +test suite decrypts every entry here, proving it can read values encrypted by +every other driver. + +Add your own entry by running that script and pasting its output below. +""" + +from dataclasses import dataclass + +import nutkit.protocol as types + + +# don't change this or the interop decryption test will break +INTEROP_PROFILE_NAME = "interop" + + +@dataclass(frozen=True) +class DecryptInteropFixture: + driver: str + kek: bytes + encapsulation: bytes + metadata: dict + encrypted: bytes + value: object + + +DECRYPT_INTEROP_TEST_CASES = [ + DecryptInteropFixture( + driver="dotnet", + kek=bytes.fromhex( + "1974e1620d8d540ffba202d9eeb616f4" + "266a731f3eadd3637f25c71bb96ad024" + ), + encapsulation=bytes.fromhex( + "014603135847721b9f3a1b1089ec98712" + "1e03c8d703df8c35b7ca95f8893c84f6f" + "e23c661aae26ef78bc9b1b6bb06905" + ), + metadata={"iv": "nJvPChdeMDkE/FDM"}, + encrypted=bytes.fromhex( + "01b66587696e7465726f70cc24eab4839" + "fe33026669beb1413b7a3581cb32edae0" + "1e2c94e41e6e4ed741e6302df4da42208" + "6535452494e470100a5866b65795f6964" + "8130826976cc0ca3feff5e0278a951c49" + "0a11483616164cc00d0126161645f7072" + "6f746f636f6c5f6d616a6f7201d012616" + "1645f70726f746f636f6c5f6d696e6f72" + "00" + ), + value=types.CypherString("hello from dotnet!"), + ), +] diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py new file mode 100644 index 000000000..a443f837e --- /dev/null +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -0,0 +1,82 @@ +""" +Generates an entry for the fixture list in decrypt_interop_fixtures.py. + +Make sure the backend is running. Edit the value at the top of the script to +whatever you like, then run the script. The script connects to the backend +and pretends to be starting a test, and creates a driver. The connection +details are nonsense because it's not actually going to try to connect to a +database, just using the encryption property. + +If your backend is passing the encryption stub tests then this script will +work, and it will print out an entry for you to paste into the FIXTURES list +in decrypt_interop_fixtures.py. The actual test in test_property_encryption.py +picks it up from there. +""" + +import secrets + +import nutkit.protocol as types +from nutkit.frontend import Driver +from tests.shared import get_driver_name, new_backend +from tests.stub.property_encryption.decrypt_interop_fixtures import ( + INTEROP_PROFILE_NAME, +) + +string_to_encrypt = "hello from dotnet!" + + +def main(): + backend = new_backend() + try: + backend.send_and_receive(types.GetFeatures()) + backend.send_and_receive( + types.StartTest("generate_decrypt_interop_fixture") + ) + + kek = secrets.token_bytes(32) + value = types.CypherString(string_to_encrypt) + auth = types.AuthorizationToken( + "basic", principal="neo4j", credentials="pass" + ) + driver = Driver( + backend, "bolt://localhost:9999", auth, + property_encryption_profiles=[ + {"name": INTEROP_PROFILE_NAME, "fixed_kek": kek} + ], + ) + try: + key = driver.create_encapsulated_key( + "k", profile_name=INTEROP_PROFILE_NAME + ) + encrypted = driver.encrypt_to_bytes( + value, profile_name=INTEROP_PROFILE_NAME, key_alias="k" + ) + finally: + driver.close() + + print_fixture_literal( + driver=get_driver_name(), + kek=kek, + encapsulation=bytes.fromhex(key.encapsulated_bytes.value), + metadata=key.metadata, + encrypted=bytes.fromhex(encrypted.value), + value=string_to_encrypt, + ) + finally: + backend.close() + + +def print_fixture_literal(*, driver, kek, encapsulation, metadata, encrypted, + value): + print("DecryptInteropFixture(") + print(f" driver={driver!r},") + print(f" kek=bytes.fromhex({kek.hex()!r}),") + print(f" encapsulation=bytes.fromhex({encapsulation.hex()!r}),") + print(f" metadata={metadata!r},") + print(f" encrypted=bytes.fromhex({encrypted.hex()!r}),") + print(f" value=types.CypherString({value!r}),") + print("),") + + +if __name__ == "__main__": + main() diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index a192872f6..6cd76835c 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -3,6 +3,10 @@ import nutkit.protocol as types from nutkit.frontend import Driver from tests.shared import TestkitTestCase +from tests.stub.property_encryption.decrypt_interop_fixtures import ( + DECRYPT_INTEROP_TEST_CASES, + INTEROP_PROFILE_NAME, +) from tests.stub.shared import StubServer @@ -129,3 +133,50 @@ def test_key_manager_raises_when_ambiguous_and_no_profile_given(self): with self.assertRaises(types.DriverError): driver.create_encapsulated_key("k1") + + def test_imported_key_decrypts_with_a_fixed_kek(self): + kek = bytes(range(32)) + + driver_1 = self._new_driver( + profiles=({"name": "fx", "fixed_kek": kek},) + ) + key = driver_1.create_encapsulated_key("k1", profile_name="fx") + encrypted = driver_1.encrypt_to_bytes( + types.CypherString("hello world"), + profile_name="fx", key_alias="k1" + ) + driver_1.close() + + driver_2 = self._new_driver( + profiles=({"name": "fx", "fixed_kek": kek},) + ) + driver_2.import_encapsulated_key( + "k1", key.encapsulated_bytes, key.metadata, profile_name="fx" + ) + + decrypted = driver_2.decrypt(encrypted, use_persisted_aad=True) + + self.assertEqual(decrypted, types.CypherString("hello world")) + + def test_decrypts_values_produced_by_other_drivers(self): + for case in DECRYPT_INTEROP_TEST_CASES: + with self.subTest(driver=case.driver): + driver = self._new_driver( + profiles=({"name": INTEROP_PROFILE_NAME, "fixed_kek": case.kek},) + ) + driver.import_encapsulated_key( + "k", types.CypherBytes(case.encapsulation), case.metadata, + profile_name=INTEROP_PROFILE_NAME + ) + + decrypted = driver.decrypt( + types.CypherBytes(case.encrypted), use_persisted_aad=True + ) + + self.assertEqual( + decrypted, case.value, + "Could not decrypt value encrypted by driver: " + f"{case.driver}" + ) + driver.close() + self._driver = None From 4eedd4088c626cc74fd9be333f72b9b1e5d84fd1 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 6 Aug 2026 12:59:56 +0100 Subject: [PATCH 07/24] Derive the fixture generator's encrypted value from the driver name string_to_encrypt was hardcoded to "hello from dotnet!", which every other driver team running this same script would have printed verbatim instead of naming their own driver. --- .../generate_decrypt_interop_fixture.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py index a443f837e..0d7fcbb19 100644 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -1,11 +1,10 @@ """ Generates an entry for the fixture list in decrypt_interop_fixtures.py. -Make sure the backend is running. Edit the value at the top of the script to -whatever you like, then run the script. The script connects to the backend -and pretends to be starting a test, and creates a driver. The connection -details are nonsense because it's not actually going to try to connect to a -database, just using the encryption property. +Make sure the backend is running, then run the script. The script connects to +the backend and pretends to be starting a test, and creates a driver. The +connection details are nonsense because it's not actually going to try to +connect to a database, just using the encryption property. If your backend is passing the encryption stub tests then this script will work, and it will print out an entry for you to paste into the FIXTURES list @@ -22,9 +21,6 @@ INTEROP_PROFILE_NAME, ) -string_to_encrypt = "hello from dotnet!" - - def main(): backend = new_backend() try: @@ -33,6 +29,7 @@ def main(): types.StartTest("generate_decrypt_interop_fixture") ) + string_to_encrypt = f"hello from {get_driver_name()}!" kek = secrets.token_bytes(32) value = types.CypherString(string_to_encrypt) auth = types.AuthorizationToken( From 70fde99ca136420c7a56b1cae768c73a25fd9ec9 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 6 Aug 2026 15:29:22 +0100 Subject: [PATCH 08/24] Fix flake8/isort style violations in the decrypt-interop check Line-length, blank-line, and import-formatting fixes; the failing testkit-style-check was cascading into every driver's composite build. Verified locally with pre-commit run. --- tests/stub/property_encryption/decrypt_interop_fixtures.py | 1 - .../generate_decrypt_interop_fixture.py | 6 +++++- tests/stub/property_encryption/test_property_encryption.py | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py index 628248c5f..be1759c1c 100644 --- a/tests/stub/property_encryption/decrypt_interop_fixtures.py +++ b/tests/stub/property_encryption/decrypt_interop_fixtures.py @@ -13,7 +13,6 @@ import nutkit.protocol as types - # don't change this or the interop decryption test will break INTEROP_PROFILE_NAME = "interop" diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py index 0d7fcbb19..ab937b93f 100644 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -16,11 +16,15 @@ import nutkit.protocol as types from nutkit.frontend import Driver -from tests.shared import get_driver_name, new_backend +from tests.shared import ( + get_driver_name, + new_backend, +) from tests.stub.property_encryption.decrypt_interop_fixtures import ( INTEROP_PROFILE_NAME, ) + def main(): backend = new_backend() try: diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 6cd76835c..afcf67697 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -162,7 +162,12 @@ def test_decrypts_values_produced_by_other_drivers(self): for case in DECRYPT_INTEROP_TEST_CASES: with self.subTest(driver=case.driver): driver = self._new_driver( - profiles=({"name": INTEROP_PROFILE_NAME, "fixed_kek": case.kek},) + profiles=( + { + "name": INTEROP_PROFILE_NAME, + "fixed_kek": case.kek, + }, + ) ) driver.import_encapsulated_key( "k", types.CypherBytes(case.encapsulation), case.metadata, From e293a255a3447971c5eb29ed980d2c80b28f8532 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Sat, 8 Aug 2026 14:36:29 +0100 Subject: [PATCH 09/24] Skip building the Rust boltstub when TEST_RUSTY_STUB isn't set The runner image unconditionally installed the Rust toolchain and ran cargo build --release regardless of which stub server the run actually uses, wasting disk and time on every non-rusty-stub job. This is the root cause behind several disk-space CI failures on this PR: jobs that never touch the Rust stub were still paying its full build cost. BUILD_RUST_STUB defaults to true (safe for a manual docker build with no extra args); runner.py now derives it from TEST_RUSTY_STUB using the same truthy check already used in tests/stub/shared.py. Verified locally: both branches build cleanly, the real path still produces a working boltstub binary, the skipped path installs no Rust toolchain at all. --- runner.py | 6 ++++++ runner_image/Dockerfile | 32 ++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/runner.py b/runner.py index 284b52f00..d09849c65 100644 --- a/runner.py +++ b/runner.py @@ -3,6 +3,11 @@ import docker +USE_RUST = ( + os.environ.get("TEST_RUSTY_STUB", "").lower() + in ("true", "y", "yes", "1", "on") +) + def _ensure_image(testkit_path, branch_name, artifacts_path): """Ensure that an up-to-date Docker image exists.""" @@ -14,6 +19,7 @@ def _ensure_image(testkit_path, branch_name, artifacts_path): image_name, image_path, log_path=artifacts_path, + args={"BUILD_RUST_STUB": "true" if USE_RUST else "false"}, build_contexts={"boltstub": boltstub_path}, ) diff --git a/runner_image/Dockerfile b/runner_image/Dockerfile index 0fb25a152..dce7d465a 100644 --- a/runner_image/Dockerfile +++ b/runner_image/Dockerfile @@ -1,17 +1,25 @@ +# syntax=docker/dockerfile:1.4 FROM ubuntu:20.04 AS build +ARG BUILD_RUST_STUB=true + WORKDIR /root/build/boltstub -RUN apt-get update && \ - apt-get install -y \ - python3 \ - python3-dev \ - python3-pip \ - curl \ - && rm -rf /var/lib/apt/lists/* -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" -RUN --mount=from=boltstub,target=/tmp/boltstub cp -r /tmp/boltstub/* . -RUN cargo build --release +RUN --mount=from=boltstub,target=/tmp/boltstub < Date: Mon, 17 Aug 2026 12:19:30 +0200 Subject: [PATCH 10/24] add javascript test --- .../decrypt_interop_fixtures.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py index be1759c1c..e0a601326 100644 --- a/tests/stub/property_encryption/decrypt_interop_fixtures.py +++ b/tests/stub/property_encryption/decrypt_interop_fixtures.py @@ -53,4 +53,25 @@ class DecryptInteropFixture: ), value=types.CypherString("hello from dotnet!"), ), + DecryptInteropFixture( + driver="javascript", + kek=bytes.fromhex( + "6f00997851909b4e7ceaa63b399a45e2e6" + "4a9dc7321244505906f989c6f7fd1b" + ), + encapsulation=bytes.fromhex( + "028d6c222298dc221c810d0bb8e02a3f3f7" + "356b2a4b6d3dd3955a1d1d3c29865546226" + "ead30bda65ebde5ac348e01131" + ), + metadata={"iv": "VBmseht6oyo67NdQ"}, + encrypted=bytes.fromhex( + "01b66587696e7465726f70cc285e928af746" + "a3671c31685941d3b104d8ef043a4424b57c" + "c198bf40caa76ad7504fa0304490cb7dbd86" + "535452494e470100a2826976cc0cae08418d" + "b8f165e9ce44b679866b65795f69648130" + ), + value=types.CypherString("hello from javascript!"), + ), ] From fe0a73fcfc126b44465a8ebe4d495743ec252b68 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 17 Aug 2026 12:30:14 +0100 Subject: [PATCH 11/24] Regenerate the javascript decrypt-interop fixture with HKDF key derivation The previous entry was generated before the JS driver applied HKDF-SHA256 to the DEK (per ADR 037), so no spec-conforming driver could decrypt it. Regenerated against the fixed JS backend; both the dotnet and javascript entries now decrypt on both backends. Also document how to invoke the generator script (module invocation from the repo root, not a plain script path). --- .../decrypt_interop_fixtures.py | 23 ++++++++++--------- .../generate_decrypt_interop_fixture.py | 7 ++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py index e0a601326..8f6de1821 100644 --- a/tests/stub/property_encryption/decrypt_interop_fixtures.py +++ b/tests/stub/property_encryption/decrypt_interop_fixtures.py @@ -56,21 +56,22 @@ class DecryptInteropFixture: DecryptInteropFixture( driver="javascript", kek=bytes.fromhex( - "6f00997851909b4e7ceaa63b399a45e2e6" - "4a9dc7321244505906f989c6f7fd1b" + "ac4828b563d2dd626d34214c3dcd8168" + "ed77c5ed9620b383e4b63665286302f9" ), encapsulation=bytes.fromhex( - "028d6c222298dc221c810d0bb8e02a3f3f7" - "356b2a4b6d3dd3955a1d1d3c29865546226" - "ead30bda65ebde5ac348e01131" + "4320112f90e2228f4dabffdc82c904bff" + "f33eb70b4b162354474cb6a389717e9df" + "e3077d9255913ee6d0e30f2a9f55f0" ), - metadata={"iv": "VBmseht6oyo67NdQ"}, + metadata={"iv": "lL9ga7QC9WWcKXH6"}, encrypted=bytes.fromhex( - "01b66587696e7465726f70cc285e928af746" - "a3671c31685941d3b104d8ef043a4424b57c" - "c198bf40caa76ad7504fa0304490cb7dbd86" - "535452494e470100a2826976cc0cae08418d" - "b8f165e9ce44b679866b65795f69648130" + "01b66587696e7465726f70cc28e4d50c7" + "76bdf240ccb0afd823376dfda1b55ba68" + "2601933fe5fc15a9276dcdd20e2cda276" + "8236a1686535452494e470100a2826976" + "cc0c343f2b095b3385d0d7adf455866b6" + "5795f69648130" ), value=types.CypherString("hello from javascript!"), ), diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py index ab937b93f..d288f8aa9 100644 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -10,6 +10,13 @@ work, and it will print out an entry for you to paste into the FIXTURES list in decrypt_interop_fixtures.py. The actual test in test_property_encryption.py picks it up from there. + +Run as a module from the repo root with your backend already listening on +:9876: + + cd ~/dev/testkit # repo root + python3 -m tests.stub.property_encryption.generate_decrypt_interop_fixture + """ import secrets From f66641d9670e7703d0de93444dae3a740f13d661 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 17 Aug 2026 12:33:40 +0100 Subject: [PATCH 12/24] Make the fixture generator print lint-clean, paste-ready entries The generator used !r formatting, which fails flake8-quotes (inline-quotes=double) and E501 (unwrapped hex literals) as soon as the output is pasted. Print double-quoted strings and 32-char hex chunks, pre-indented to drop straight into DECRYPT_INTEROP_TEST_CASES verbatim. Reformat the javascript entry to match the generator output exactly (same bytes, new wrapping). --- .../decrypt_interop_fixtures.py | 18 ++++++------- .../generate_decrypt_interop_fixture.py | 27 +++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py index 8f6de1821..26d50e600 100644 --- a/tests/stub/property_encryption/decrypt_interop_fixtures.py +++ b/tests/stub/property_encryption/decrypt_interop_fixtures.py @@ -60,18 +60,18 @@ class DecryptInteropFixture: "ed77c5ed9620b383e4b63665286302f9" ), encapsulation=bytes.fromhex( - "4320112f90e2228f4dabffdc82c904bff" - "f33eb70b4b162354474cb6a389717e9df" - "e3077d9255913ee6d0e30f2a9f55f0" + "4320112f90e2228f4dabffdc82c904bf" + "ff33eb70b4b162354474cb6a389717e9" + "dfe3077d9255913ee6d0e30f2a9f55f0" ), metadata={"iv": "lL9ga7QC9WWcKXH6"}, encrypted=bytes.fromhex( - "01b66587696e7465726f70cc28e4d50c7" - "76bdf240ccb0afd823376dfda1b55ba68" - "2601933fe5fc15a9276dcdd20e2cda276" - "8236a1686535452494e470100a2826976" - "cc0c343f2b095b3385d0d7adf455866b6" - "5795f69648130" + "01b66587696e7465726f70cc28e4d50c" + "776bdf240ccb0afd823376dfda1b55ba" + "682601933fe5fc15a9276dcdd20e2cda" + "2768236a1686535452494e470100a282" + "6976cc0c343f2b095b3385d0d7adf455" + "866b65795f69648130" ), value=types.CypherString("hello from javascript!"), ), diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py index d288f8aa9..deb460e16 100644 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -76,14 +76,25 @@ def main(): def print_fixture_literal(*, driver, kek, encapsulation, metadata, encrypted, value): - print("DecryptInteropFixture(") - print(f" driver={driver!r},") - print(f" kek=bytes.fromhex({kek.hex()!r}),") - print(f" encapsulation=bytes.fromhex({encapsulation.hex()!r}),") - print(f" metadata={metadata!r},") - print(f" encrypted=bytes.fromhex({encrypted.hex()!r}),") - print(f" value=types.CypherString({value!r}),") - print("),") + metadata_literal = ", ".join( + f'"{key}": "{val}"' for key, val in metadata.items() + ) + print(" DecryptInteropFixture(") + print(f' driver="{driver}",') + print_bytes_literal("kek", kek) + print_bytes_literal("encapsulation", encapsulation) + print(f" metadata={{{metadata_literal}}},") + print_bytes_literal("encrypted", encrypted) + print(f' value=types.CypherString("{value}"),') + print(" ),") + + +def print_bytes_literal(field, value): + print(f" {field}=bytes.fromhex(") + hex_string = value.hex() + for i in range(0, len(hex_string), 32): + print(f' "{hex_string[i:i + 32]}"') + print(" ),") if __name__ == "__main__": From 3f0c71767da6f0b3397bb49611d6acac513eb162 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 19 Aug 2026 08:54:53 +0100 Subject: [PATCH 13/24] Add Backend:MockRandom for deterministic property-encryption tests NewDriver.mockRandom lets TestKit ask the backend to replace the driver's CSPRNG seam with a mock for that driver's lifetime; EncryptToBytes.mockRandomBytes then supplies the exact bytes for one encrypt call. Verified: two encrypts of the same value with the same mock bytes produce identical ciphertext, and supplying mock bytes without mockRandom raises. --- nutkit/frontend/driver.py | 13 ++++-- nutkit/protocol/feature.py | 5 +++ nutkit/protocol/requests.py | 13 +++++- .../test_property_encryption.py | 41 +++++++++++++++++-- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index f04ac2b11..00f658c1c 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -25,7 +25,8 @@ def __init__(self, backend, uri, auth_token, user_agent=None, telemetry_disabled=None, client_certificate=None, disable_auto_commit_retries=None, - property_encryption_profiles=None): + property_encryption_profiles=None, + mock_random=False): self._backend = backend self._resolver_fn = resolver_fn self._domain_name_resolver_fn = domain_name_resolver_fn @@ -80,6 +81,7 @@ def __init__(self, backend, uri, auth_token, user_agent=None, client_certificate_provider_id=client_certificate_provider_id_, disable_auto_commit_retries=disable_auto_commit_retries, property_encryption_profiles=property_encryption_profiles_, + mock_random=mock_random, ) res = backend.send_and_receive(req) if not isinstance(res, protocol.Driver): @@ -208,10 +210,15 @@ def is_encrypted(self): return res.encrypted def encrypt_to_bytes(self, value, *, aad=None, profile_name=None, - key_alias=None, key_id=None): + key_alias=None, key_id=None, + mock_random_bytes=None): + randoms = None + if mock_random_bytes is not None: + randoms = protocol.CypherBytes(mock_random_bytes) req = protocol.EncryptToBytes( self._driver.id, value, aad=aad, profile_name=profile_name, - key_alias=key_alias, key_id=key_id + key_alias=key_alias, key_id=key_id, + mock_random_bytes=randoms ) res = self.send_and_receive(req, allow_resolution=False) if not isinstance(res, protocol.EncryptedValue): diff --git a/nutkit/protocol/feature.py b/nutkit/protocol/feature.py index 504aea581..bebf485a1 100644 --- a/nutkit/protocol/feature.py +++ b/nutkit/protocol/feature.py @@ -254,6 +254,11 @@ class Feature(Enum): # FakeTimeTick protocol messages and provides a way to mock the system # time. This is mainly used for testing various timeouts. BACKEND_MOCK_TIME = "Backend:MockTime" + # The backend understands NewDriver.mockRandom and + # EncryptToBytes.mockRandomBytes, letting TestKit supply the exact bytes + # a property-encryption driver draws from its random generator. Used to + # assert byte-exact ciphertext for the deterministic encryption tests. + BACKEND_MOCK_RANDOM = "Backend:MockRandom" # The backend understands the GetRoutingTable protocol message and provides # a way for TestKit to request the routing table (for testing only, should # not be exposed to the user). diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index fa48f5ff0..097bbf651 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -80,6 +80,7 @@ def __init__( client_certificate=None, client_certificate_provider_id=None, disable_auto_commit_retries=None, property_encryption_profiles=None, + mock_random=False, ): # Neo4j URI to connect to self.uri = uri @@ -113,6 +114,10 @@ def __init__( self.disableAutoCommitRetries = disable_auto_commit_retries if property_encryption_profiles is not None: self.propertyEncryptionProfiles = property_encryption_profiles + # (bool) whether the driver's property-encryption CSPRNG seam is + # replaced with a backend-side mock that TestKit feeds via + # EncryptToBytes.mockRandomBytes + self.mockRandom = mock_random # (bool) whether to enable or disable encryption # field missing in message: use driver default (should be False) if encrypted is not None: @@ -913,16 +918,22 @@ class EncryptToBytes: :param key_id: The repository-assigned id of the data encryption key to encrypt with. Mutually exclusive with key_alias; exactly one of the two must be set. + :param mock_random_bytes: The exact bytes for the driver's mocked CSPRNG + seam to hand back for this encrypt call, or None to draw real + randomness. Only valid when the driver was created with + NewDriver(mock_random=True); the backend raises if the driver's + random draws don't consume exactly these bytes. """ def __init__(self, driver_id, value, aad=None, profile_name=None, - key_alias=None, key_id=None): + key_alias=None, key_id=None, mock_random_bytes=None): self.driverId = driver_id self.value = value self.aad = aad self.profileName = profile_name self.keyAlias = key_alias self.keyId = key_id + self.mockRandomBytes = mock_random_bytes class Decrypt: diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index afcf67697..cf52ab3c3 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -2,7 +2,10 @@ import nutkit.protocol as types from nutkit.frontend import Driver -from tests.shared import TestkitTestCase +from tests.shared import ( + driver_feature, + TestkitTestCase, +) from tests.stub.property_encryption.decrypt_interop_fixtures import ( DECRYPT_INTEROP_TEST_CASES, INTEROP_PROFILE_NAME, @@ -23,13 +26,14 @@ def tearDown(self): self._driver.close() return super().tearDown() - def _new_driver(self, profiles=("default",)): + def _new_driver(self, profiles=("default",), mock_random=False): auth = types.AuthorizationToken("basic", principal="neo4j", credentials="pass") uri = "bolt://%s" % self._server.address self._driver = Driver( self._backend, uri, auth, - property_encryption_profiles=list(profiles) + property_encryption_profiles=list(profiles), + mock_random=mock_random ) return self._driver @@ -89,6 +93,37 @@ def test_encrypting_the_same_value_twice_yields_different_ciphertext(self): self.assertNotEqual(first, second) + @driver_feature(types.Feature.BACKEND_MOCK_RANDOM) + def test_mock_random_produces_identical_ciphertext_for_identical_bytes( + self + ): + driver = self._new_driver(mock_random=True) + driver.create_encapsulated_key("k1") + + iv = bytes(range(12)) + value = types.CypherString("hello world") + first = driver.encrypt_to_bytes( + value, key_alias="k1", mock_random_bytes=iv + ) + second = driver.encrypt_to_bytes( + value, key_alias="k1", mock_random_bytes=iv + ) + + self.assertEqual(first, second) + + @driver_feature(types.Feature.BACKEND_MOCK_RANDOM) + def test_encrypt_raises_when_mock_random_bytes_given_without_mock_random( + self + ): + driver = self._new_driver(mock_random=False) + driver.create_encapsulated_key("k1") + + with self.assertRaises(types.DriverError): + driver.encrypt_to_bytes( + types.CypherString("hello world"), key_alias="k1", + mock_random_bytes=bytes(range(12)) + ) + def test_decrypt_raises_on_wrong_aad(self): driver = self._new_driver() driver.create_encapsulated_key("k1") From f49760c6f86d032f13b1468806e98488396a4579 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 19 Aug 2026 10:01:29 +0100 Subject: [PATCH 14/24] Replace Backend:MockRandom with a per-call fixedIv on EncryptToBytes No feature flag and no NewDriver field: fixedIv pins the 12-byte IV for one encrypt call, omitting it draws real randomness. The backend raises if the IV is the wrong length or the operation doesn't consume it. --- nutkit/frontend/driver.py | 16 +++----- nutkit/protocol/feature.py | 5 --- nutkit/protocol/requests.py | 19 ++++------ .../test_property_encryption.py | 38 ++++--------------- 4 files changed, 20 insertions(+), 58 deletions(-) diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index 00f658c1c..25b19816a 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -25,8 +25,7 @@ def __init__(self, backend, uri, auth_token, user_agent=None, telemetry_disabled=None, client_certificate=None, disable_auto_commit_retries=None, - property_encryption_profiles=None, - mock_random=False): + property_encryption_profiles=None): self._backend = backend self._resolver_fn = resolver_fn self._domain_name_resolver_fn = domain_name_resolver_fn @@ -81,7 +80,6 @@ def __init__(self, backend, uri, auth_token, user_agent=None, client_certificate_provider_id=client_certificate_provider_id_, disable_auto_commit_retries=disable_auto_commit_retries, property_encryption_profiles=property_encryption_profiles_, - mock_random=mock_random, ) res = backend.send_and_receive(req) if not isinstance(res, protocol.Driver): @@ -210,15 +208,13 @@ def is_encrypted(self): return res.encrypted def encrypt_to_bytes(self, value, *, aad=None, profile_name=None, - key_alias=None, key_id=None, - mock_random_bytes=None): - randoms = None - if mock_random_bytes is not None: - randoms = protocol.CypherBytes(mock_random_bytes) + key_alias=None, key_id=None, fixed_iv=None): + fixed_iv_ = None + if fixed_iv is not None: + fixed_iv_ = protocol.CypherBytes(fixed_iv) req = protocol.EncryptToBytes( self._driver.id, value, aad=aad, profile_name=profile_name, - key_alias=key_alias, key_id=key_id, - mock_random_bytes=randoms + key_alias=key_alias, key_id=key_id, fixed_iv=fixed_iv_ ) res = self.send_and_receive(req, allow_resolution=False) if not isinstance(res, protocol.EncryptedValue): diff --git a/nutkit/protocol/feature.py b/nutkit/protocol/feature.py index bebf485a1..504aea581 100644 --- a/nutkit/protocol/feature.py +++ b/nutkit/protocol/feature.py @@ -254,11 +254,6 @@ class Feature(Enum): # FakeTimeTick protocol messages and provides a way to mock the system # time. This is mainly used for testing various timeouts. BACKEND_MOCK_TIME = "Backend:MockTime" - # The backend understands NewDriver.mockRandom and - # EncryptToBytes.mockRandomBytes, letting TestKit supply the exact bytes - # a property-encryption driver draws from its random generator. Used to - # assert byte-exact ciphertext for the deterministic encryption tests. - BACKEND_MOCK_RANDOM = "Backend:MockRandom" # The backend understands the GetRoutingTable protocol message and provides # a way for TestKit to request the routing table (for testing only, should # not be exposed to the user). diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index 097bbf651..747d54386 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -80,7 +80,6 @@ def __init__( client_certificate=None, client_certificate_provider_id=None, disable_auto_commit_retries=None, property_encryption_profiles=None, - mock_random=False, ): # Neo4j URI to connect to self.uri = uri @@ -114,10 +113,6 @@ def __init__( self.disableAutoCommitRetries = disable_auto_commit_retries if property_encryption_profiles is not None: self.propertyEncryptionProfiles = property_encryption_profiles - # (bool) whether the driver's property-encryption CSPRNG seam is - # replaced with a backend-side mock that TestKit feeds via - # EncryptToBytes.mockRandomBytes - self.mockRandom = mock_random # (bool) whether to enable or disable encryption # field missing in message: use driver default (should be False) if encrypted is not None: @@ -918,22 +913,22 @@ class EncryptToBytes: :param key_id: The repository-assigned id of the data encryption key to encrypt with. Mutually exclusive with key_alias; exactly one of the two must be set. - :param mock_random_bytes: The exact bytes for the driver's mocked CSPRNG - seam to hand back for this encrypt call, or None to draw real - randomness. Only valid when the driver was created with - NewDriver(mock_random=True); the backend raises if the driver's - random draws don't consume exactly these bytes. + :param fixed_iv: The exact 12-byte IV the driver must use for this + encrypt call, or None to draw a random one. The backend raises if + the IV is not exactly 12 bytes or the operation doesn't consume it. + Used to assert byte-exact ciphertext in the deterministic + encryption tests. """ def __init__(self, driver_id, value, aad=None, profile_name=None, - key_alias=None, key_id=None, mock_random_bytes=None): + key_alias=None, key_id=None, fixed_iv=None): self.driverId = driver_id self.value = value self.aad = aad self.profileName = profile_name self.keyAlias = key_alias self.keyId = key_id - self.mockRandomBytes = mock_random_bytes + self.fixedIv = fixed_iv class Decrypt: diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index cf52ab3c3..c25e2a35d 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -2,10 +2,7 @@ import nutkit.protocol as types from nutkit.frontend import Driver -from tests.shared import ( - driver_feature, - TestkitTestCase, -) +from tests.shared import TestkitTestCase from tests.stub.property_encryption.decrypt_interop_fixtures import ( DECRYPT_INTEROP_TEST_CASES, INTEROP_PROFILE_NAME, @@ -26,14 +23,13 @@ def tearDown(self): self._driver.close() return super().tearDown() - def _new_driver(self, profiles=("default",), mock_random=False): + def _new_driver(self, profiles=("default",)): auth = types.AuthorizationToken("basic", principal="neo4j", credentials="pass") uri = "bolt://%s" % self._server.address self._driver = Driver( self._backend, uri, auth, - property_encryption_profiles=list(profiles), - mock_random=mock_random + property_encryption_profiles=list(profiles) ) return self._driver @@ -93,37 +89,17 @@ def test_encrypting_the_same_value_twice_yields_different_ciphertext(self): self.assertNotEqual(first, second) - @driver_feature(types.Feature.BACKEND_MOCK_RANDOM) - def test_mock_random_produces_identical_ciphertext_for_identical_bytes( - self - ): - driver = self._new_driver(mock_random=True) + def test_fixed_iv_produces_identical_ciphertext(self): + driver = self._new_driver() driver.create_encapsulated_key("k1") iv = bytes(range(12)) value = types.CypherString("hello world") - first = driver.encrypt_to_bytes( - value, key_alias="k1", mock_random_bytes=iv - ) - second = driver.encrypt_to_bytes( - value, key_alias="k1", mock_random_bytes=iv - ) + first = driver.encrypt_to_bytes(value, key_alias="k1", fixed_iv=iv) + second = driver.encrypt_to_bytes(value, key_alias="k1", fixed_iv=iv) self.assertEqual(first, second) - @driver_feature(types.Feature.BACKEND_MOCK_RANDOM) - def test_encrypt_raises_when_mock_random_bytes_given_without_mock_random( - self - ): - driver = self._new_driver(mock_random=False) - driver.create_encapsulated_key("k1") - - with self.assertRaises(types.DriverError): - driver.encrypt_to_bytes( - types.CypherString("hello world"), key_alias="k1", - mock_random_bytes=bytes(range(12)) - ) - def test_decrypt_raises_on_wrong_aad(self): driver = self._new_driver() driver.create_encapsulated_key("k1") From 4a46206752b99426d8bb247b45f6f6956d1bec5e Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Wed, 19 Aug 2026 10:39:01 +0100 Subject: [PATCH 15/24] Add deterministic encryption test with known-answer fixtures Every input is pinned - KEK, data-encryption key (via its recorded encapsulation) and per-entry IVs - so any conformant driver must encrypt these values to exactly these bytes. Entries cover the supported property types plus an AAD-bound value, initially generated with the .NET backend; cross-driver disagreement gets investigated when it appears. --- .../deterministic_fixtures.py | 145 ++++++++++++++++++ .../test_property_encryption.py | 32 ++++ 2 files changed, 177 insertions(+) create mode 100644 tests/stub/property_encryption/deterministic_fixtures.py diff --git a/tests/stub/property_encryption/deterministic_fixtures.py b/tests/stub/property_encryption/deterministic_fixtures.py new file mode 100644 index 000000000..7cd3c56e8 --- /dev/null +++ b/tests/stub/property_encryption/deterministic_fixtures.py @@ -0,0 +1,145 @@ +# These entries were initially generated with the .NET driver. + +from dataclasses import dataclass + +import nutkit.protocol as types + +DETERMINISTIC_PROFILE_NAME = "deterministic" + +DETERMINISTIC_KEK = bytes.fromhex( + "4feaec7d8374cb75bd5045c079a2adaa" + "7a7a1f3b16273fdd14b473f651398b5a" +) +DETERMINISTIC_ENCAPSULATION = bytes.fromhex( + "e05287622c56ada2a11bb05c346b08c2" + "c1fef76c4e0f8be89adb6193a6414318" + "5a56c5daa1002eacd8bb2d557762b48e" +) +DETERMINISTIC_KEY_METADATA = {"iv": "pYQJeZ/fdg4ooz8I"} + + +@dataclass(frozen=True) +class DeterministicFixture: + value: object + iv: bytes + encrypted: bytes + aad: object = None + + +DETERMINISTIC_TEST_CASES = [ + DeterministicFixture( + value=types.CypherBool(True), + iv=bytes.fromhex( + "000102030405060708090a0b" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc11fc4171fe16c17467bbca0418c2" + "4041622487424f4f4c45414e0100a586" + "6b65795f69648130826976cc0c000102" + "030405060708090a0b83616164cc00d0" + "126161645f70726f746f636f6c5f6d61" + "6a6f7201d0126161645f70726f746f63" + "6f6c5f6d696e6f7200" + ), + ), + DeterministicFixture( + value=types.CypherInt(32768), + iv=bytes.fromhex( + "0c0d0e0f1011121314151617" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc157289ad84e2905a1a934cfb4fdd" + "99946880fbb0ca1f87494e5445474552" + "0100a5866b65795f69648130826976cc" + "0c0c0d0e0f1011121314151617836161" + "64cc00d0126161645f70726f746f636f" + "6c5f6d616a6f7201d0126161645f7072" + "6f746f636f6c5f6d696e6f7200" + ), + ), + DeterministicFixture( + value=types.CypherFloat(3.25), + iv=bytes.fromhex( + "18191a1b1c1d1e1f20212223" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc19e3ecdce6cf2b3d75ab2b863a22" + "4d16756c1ae96696fd287e8485464c4f" + "41540100a5866b65795f696481308269" + "76cc0c18191a1b1c1d1e1f2021222383" + "616164cc00d0126161645f70726f746f" + "636f6c5f6d616a6f7201d0126161645f" + "70726f746f636f6c5f6d696e6f7200" + ), + ), + DeterministicFixture( + value=types.CypherString("hello world"), + iv=bytes.fromhex( + "2425262728292a2b2c2d2e2f" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc1ca3ee14303c4684c2ec4db78ddc" + "91f6291586b7927f19ac3aa142341e86" + "535452494e470100a5866b65795f6964" + "8130826976cc0c2425262728292a2b2c" + "2d2e2f83616164cc00d0126161645f70" + "726f746f636f6c5f6d616a6f7201d012" + "6161645f70726f746f636f6c5f6d696e" + "6f7200" + ), + ), + DeterministicFixture( + value=types.CypherBytes(b"\x00\x01\x02"), + iv=bytes.fromhex( + "303132333435363738393a3b" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc153356122560582ac73ba05eeaef" + "5293b7008512ade78542595445530100" + "a5866b65795f69648130826976cc0c30" + "3132333435363738393a3b83616164cc" + "00d0126161645f70726f746f636f6c5f" + "6d616a6f7201d0126161645f70726f74" + "6f636f6c5f6d696e6f7200" + ), + ), + DeterministicFixture( + value=types.CypherList([types.CypherInt(1), types.CypherInt(2)]), + iv=bytes.fromhex( + "3c3d3e3f4041424344454647" + ), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc137f5458987009880d7077c665b4" + "2f4735b83501844c4953540100a5866b" + "65795f69648130826976cc0c3c3d3e3f" + "404142434445464783616164cc00d012" + "6161645f70726f746f636f6c5f6d616a" + "6f7201d0126161645f70726f746f636f" + "6c5f6d696e6f7200" + ), + ), + DeterministicFixture( + value=types.CypherString("aad-bound"), + iv=bytes.fromhex( + "48494a4b4c4d4e4f50515253" + ), + aad=types.CypherString("row-42"), + encrypted=bytes.fromhex( + "01b6658d64657465726d696e69737469" + "63cc1aa1c3405cd0e1b61e32045c2c58" + "c2f76c8ba90da91422480a4413865354" + "52494e470100a5866b65795f69648130" + "826976cc0c48494a4b4c4d4e4f505152" + "5383616164cc0786726f772d3432d012" + "6161645f70726f746f636f6c5f6d616a" + "6f7201d0126161645f70726f746f636f" + "6c5f6d696e6f7200" + ), + ), +] diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index c25e2a35d..7de8487e7 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -7,6 +7,13 @@ DECRYPT_INTEROP_TEST_CASES, INTEROP_PROFILE_NAME, ) +from tests.stub.property_encryption.deterministic_fixtures import ( + DETERMINISTIC_ENCAPSULATION, + DETERMINISTIC_KEK, + DETERMINISTIC_KEY_METADATA, + DETERMINISTIC_PROFILE_NAME, + DETERMINISTIC_TEST_CASES, +) from tests.stub.shared import StubServer @@ -169,6 +176,31 @@ def test_imported_key_decrypts_with_a_fixed_kek(self): self.assertEqual(decrypted, types.CypherString("hello world")) + def test_encrypts_to_known_bytes(self): + driver = self._new_driver( + profiles=( + { + "name": DETERMINISTIC_PROFILE_NAME, + "fixed_kek": DETERMINISTIC_KEK, + }, + ) + ) + driver.import_encapsulated_key( + "k", types.CypherBytes(DETERMINISTIC_ENCAPSULATION), + DETERMINISTIC_KEY_METADATA, + profile_name=DETERMINISTIC_PROFILE_NAME + ) + + for case in DETERMINISTIC_TEST_CASES: + with self.subTest(value=case.value): + encrypted = driver.encrypt_to_bytes( + case.value, profile_name=DETERMINISTIC_PROFILE_NAME, + key_alias="k", fixed_iv=case.iv, aad=case.aad + ) + self.assertEqual( + encrypted, types.CypherBytes(case.encrypted) + ) + def test_decrypts_values_produced_by_other_drivers(self): for case in DECRYPT_INTEROP_TEST_CASES: with self.subTest(driver=case.driver): From 9b264a1d7751d192dd5f16c6994c72ceb77e021b Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 20 Aug 2026 09:24:35 +0100 Subject: [PATCH 16/24] Test that decrypt honours the persisted AAD on an AAD-bound value The suite only ever used use_persisted_aad on values encrypted without AAD, so a driver that silently ignores the persisted AAD still passed. --- .../property_encryption/test_property_encryption.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 7de8487e7..5435134ed 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -107,6 +107,19 @@ def test_fixed_iv_produces_identical_ciphertext(self): self.assertEqual(first, second) + def test_decrypts_an_aad_bound_value_with_the_persisted_aad(self): + driver = self._new_driver() + driver.create_encapsulated_key("k1") + + encrypted = driver.encrypt_to_bytes( + types.CypherString("aad-bound"), + aad=types.CypherString("row-42"), + key_alias="k1" + ) + decrypted = driver.decrypt(encrypted, use_persisted_aad=True) + + self.assertEqual(decrypted, types.CypherString("aad-bound")) + def test_decrypt_raises_on_wrong_aad(self): driver = self._new_driver() driver.create_encapsulated_key("k1") From cce49ca8b433e628c1b8f8a25d2e9a6e96239ba5 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 20 Aug 2026 10:21:44 +0100 Subject: [PATCH 17/24] remove number too big for js --- tests/stub/property_encryption/test_property_encryption.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 5435134ed..f64accbb8 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -60,7 +60,7 @@ def test_round_trips_many_values_in_shuffled_order(self): types.CypherBool(False), types.CypherInt(0), types.CypherInt(-1), - types.CypherInt(9223372036854775807), + types.CypherInt(32768), types.CypherFloat(3.25), types.CypherString(""), types.CypherString("a"), From 642a43d26b032fd23e709b25a2ffbdcb907bc446 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 20 Aug 2026 11:41:18 +0100 Subject: [PATCH 18/24] Regenerate deterministic fixtures for ADR-conformant AAD metadata When no AAD is bound, aad and its companion fields are omitted from the metadata entirely ("The Authenticated Data, if available"); when AAD is present, the companions use the current ADR names aad_encoding_scheme_major/minor rather than aad_protocol_major/minor. --- .../deterministic_fixtures.py | 87 ++++++++----------- 1 file changed, 35 insertions(+), 52 deletions(-) diff --git a/tests/stub/property_encryption/deterministic_fixtures.py b/tests/stub/property_encryption/deterministic_fixtures.py index 7cd3c56e8..357af4e61 100644 --- a/tests/stub/property_encryption/deterministic_fixtures.py +++ b/tests/stub/property_encryption/deterministic_fixtures.py @@ -7,15 +7,15 @@ DETERMINISTIC_PROFILE_NAME = "deterministic" DETERMINISTIC_KEK = bytes.fromhex( - "4feaec7d8374cb75bd5045c079a2adaa" - "7a7a1f3b16273fdd14b473f651398b5a" + "a5be5a4e954ce4bad98c9fda44d6888c" + "26e811b64daffec8918676f3ebf6220e" ) DETERMINISTIC_ENCAPSULATION = bytes.fromhex( - "e05287622c56ada2a11bb05c346b08c2" - "c1fef76c4e0f8be89adb6193a6414318" - "5a56c5daa1002eacd8bb2d557762b48e" + "f1b17045c1163d57cd611a26149439a8" + "9ba087d37a4d4aa77dc6087e06c6f7fd" + "eac9827f50e93a5361f6d5923bf2923b" ) -DETERMINISTIC_KEY_METADATA = {"iv": "pYQJeZ/fdg4ooz8I"} +DETERMINISTIC_KEY_METADATA = {"iv": "U81P4B2CvQt2ykg3"} @dataclass(frozen=True) @@ -34,13 +34,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc11fc4171fe16c17467bbca0418c2" - "4041622487424f4f4c45414e0100a586" + "63cc11aaf92b0356bb039bb881e09c11" + "6a9ef99687424f4f4c45414e0100a286" "6b65795f69648130826976cc0c000102" - "030405060708090a0b83616164cc00d0" - "126161645f70726f746f636f6c5f6d61" - "6a6f7201d0126161645f70726f746f63" - "6f6c5f6d696e6f7200" + "030405060708090a0b" ), ), DeterministicFixture( @@ -50,13 +47,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc157289ad84e2905a1a934cfb4fdd" - "99946880fbb0ca1f87494e5445474552" - "0100a5866b65795f69648130826976cc" - "0c0c0d0e0f1011121314151617836161" - "64cc00d0126161645f70726f746f636f" - "6c5f6d616a6f7201d0126161645f7072" - "6f746f636f6c5f6d696e6f7200" + "63cc150eb3e58bb9d7ffec21f8dbb7ec" + "c8d41a9d0fd4b4b787494e5445474552" + "0100a2866b65795f69648130826976cc" + "0c0c0d0e0f1011121314151617" ), ), DeterministicFixture( @@ -66,13 +60,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc19e3ecdce6cf2b3d75ab2b863a22" - "4d16756c1ae96696fd287e8485464c4f" - "41540100a5866b65795f696481308269" - "76cc0c18191a1b1c1d1e1f2021222383" - "616164cc00d0126161645f70726f746f" - "636f6c5f6d616a6f7201d0126161645f" - "70726f746f636f6c5f6d696e6f7200" + "63cc19a835184ebf41b0e024656da074" + "b80d02379b4fd866290068eb85464c4f" + "41540100a2866b65795f696481308269" + "76cc0c18191a1b1c1d1e1f20212223" ), ), DeterministicFixture( @@ -82,14 +73,11 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1ca3ee14303c4684c2ec4db78ddc" - "91f6291586b7927f19ac3aa142341e86" - "535452494e470100a5866b65795f6964" + "63cc1c46d9914fd25236d444f2a0f59e" + "a321611003d9b08f79acaeb1975e6a86" + "535452494e470100a2866b65795f6964" "8130826976cc0c2425262728292a2b2c" - "2d2e2f83616164cc00d0126161645f70" - "726f746f636f6c5f6d616a6f7201d012" - "6161645f70726f746f636f6c5f6d696e" - "6f7200" + "2d2e2f" ), ), DeterministicFixture( @@ -99,13 +87,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc153356122560582ac73ba05eeaef" - "5293b7008512ade78542595445530100" - "a5866b65795f69648130826976cc0c30" - "3132333435363738393a3b83616164cc" - "00d0126161645f70726f746f636f6c5f" - "6d616a6f7201d0126161645f70726f74" - "6f636f6c5f6d696e6f7200" + "63cc1585b226048348bcbc289d6e46cf" + "0d7f1fb9d0e105188542595445530100" + "a2866b65795f69648130826976cc0c30" + "3132333435363738393a3b" ), ), DeterministicFixture( @@ -115,13 +100,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc137f5458987009880d7077c665b4" - "2f4735b83501844c4953540100a5866b" + "63cc1340f23b075078ae0cceccacb3e5" + "1073a65227ef844c4953540100a2866b" "65795f69648130826976cc0c3c3d3e3f" - "404142434445464783616164cc00d012" - "6161645f70726f746f636f6c5f6d616a" - "6f7201d0126161645f70726f746f636f" - "6c5f6d696e6f7200" + "4041424344454647" ), ), DeterministicFixture( @@ -132,14 +114,15 @@ class DeterministicFixture: aad=types.CypherString("row-42"), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1aa1c3405cd0e1b61e32045c2c58" - "c2f76c8ba90da91422480a4413865354" + "63cc1ae485f081fdbbc772219eb19708" + "193afbc72f29d7e958caf65f1f865354" "52494e470100a5866b65795f69648130" "826976cc0c48494a4b4c4d4e4f505152" - "5383616164cc0786726f772d3432d012" - "6161645f70726f746f636f6c5f6d616a" - "6f7201d0126161645f70726f746f636f" - "6c5f6d696e6f7200" + "5383616164cc0786726f772d3432d019" + "6161645f656e636f64696e675f736368" + "656d655f6d616a6f7201d0196161645f" + "656e636f64696e675f736368656d655f" + "6d696e6f7200" ), ), ] From 0fff674ac2d17d3538175055f7ae8098347621dd Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 20 Aug 2026 12:58:46 +0100 Subject: [PATCH 19/24] Rename fixedIv/fixedKek to iv/kek and encode as bare hex on the wire Renames the deterministic-testing protocol fields to their settled names, and switches iv/kek/encapsulation/encryptedBytes/encapsulatedBytes from CypherBytes envelopes to bare space-separated hex strings, matching the CypherVector precedent for protocol-level bytes that aren't themselves a Cypher property value. Response classes decode straight to bytes so callers work with real bytes objects instead of CypherBytes wrappers. --- nutkit/frontend/driver.py | 18 +++++++------- nutkit/protocol/requests.py | 6 ++--- nutkit/protocol/responses.py | 4 ++-- .../generate_decrypt_interop_fixture.py | 6 ++--- .../test_property_encryption.py | 24 +++++++++---------- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index 25b19816a..108c27ba7 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -92,9 +92,9 @@ def _encryption_profile_wire(profile): if isinstance(profile, str): return {"name": profile} wire = {"name": profile["name"]} - fixed_kek = profile.get("fixed_kek") - if fixed_kek is not None: - wire["fixedKek"] = protocol.CypherBytes(fixed_kek) + kek = profile.get("kek") + if kek is not None: + wire["kek"] = kek.hex(" ") return wire def receive(self, timeout=None, hooks=None, *, allow_resolution): @@ -208,13 +208,11 @@ def is_encrypted(self): return res.encrypted def encrypt_to_bytes(self, value, *, aad=None, profile_name=None, - key_alias=None, key_id=None, fixed_iv=None): - fixed_iv_ = None - if fixed_iv is not None: - fixed_iv_ = protocol.CypherBytes(fixed_iv) + key_alias=None, key_id=None, iv=None): + iv_ = iv.hex(" ") if iv is not None else None req = protocol.EncryptToBytes( self._driver.id, value, aad=aad, profile_name=profile_name, - key_alias=key_alias, key_id=key_id, fixed_iv=fixed_iv_ + key_alias=key_alias, key_id=key_id, iv=iv_ ) res = self.send_and_receive(req, allow_resolution=False) if not isinstance(res, protocol.EncryptedValue): @@ -223,7 +221,7 @@ def encrypt_to_bytes(self, value, *, aad=None, profile_name=None, def decrypt(self, value, *, aad=None, use_persisted_aad=False): req = protocol.Decrypt( - self._driver.id, value, aad=aad, + self._driver.id, value.hex(" "), aad=aad, use_persisted_aad=use_persisted_aad ) res = self.send_and_receive(req, allow_resolution=False) @@ -243,7 +241,7 @@ def create_encapsulated_key(self, alias, *, profile_name=None): def import_encapsulated_key(self, alias, encapsulation, metadata, *, profile_name=None): req = protocol.ImportEncapsulatedKey( - self._driver.id, alias, encapsulation, metadata, + self._driver.id, alias, encapsulation.hex(" "), metadata, profile_name=profile_name ) res = self.send_and_receive(req, allow_resolution=False) diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index 747d54386..c7990a03a 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -913,7 +913,7 @@ class EncryptToBytes: :param key_id: The repository-assigned id of the data encryption key to encrypt with. Mutually exclusive with key_alias; exactly one of the two must be set. - :param fixed_iv: The exact 12-byte IV the driver must use for this + :param iv: The exact 12-byte IV the driver must use for this encrypt call, or None to draw a random one. The backend raises if the IV is not exactly 12 bytes or the operation doesn't consume it. Used to assert byte-exact ciphertext in the deterministic @@ -921,14 +921,14 @@ class EncryptToBytes: """ def __init__(self, driver_id, value, aad=None, profile_name=None, - key_alias=None, key_id=None, fixed_iv=None): + key_alias=None, key_id=None, iv=None): self.driverId = driver_id self.value = value self.aad = aad self.profileName = profile_name self.keyAlias = key_alias self.keyId = key_id - self.fixedIv = fixed_iv + self.iv = iv class Decrypt: diff --git a/nutkit/protocol/responses.py b/nutkit/protocol/responses.py index 47ec424d6..bf187900d 100644 --- a/nutkit/protocol/responses.py +++ b/nutkit/protocol/responses.py @@ -351,7 +351,7 @@ class EncryptedValue: """ def __init__(self, encryptedBytes): - self.encrypted_bytes = encryptedBytes + self.encrypted_bytes = bytes.fromhex(encryptedBytes) class DecryptedValue: @@ -375,7 +375,7 @@ class EncapsulatedKey: def __init__(self, id, alias, encapsulatedBytes, metadata): self.id = id self.alias = alias - self.encapsulated_bytes = encapsulatedBytes + self.encapsulated_bytes = bytes.fromhex(encapsulatedBytes) self.metadata = metadata diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py index deb460e16..9c69722e3 100644 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py @@ -49,7 +49,7 @@ def main(): driver = Driver( backend, "bolt://localhost:9999", auth, property_encryption_profiles=[ - {"name": INTEROP_PROFILE_NAME, "fixed_kek": kek} + {"name": INTEROP_PROFILE_NAME, "kek": kek} ], ) try: @@ -65,9 +65,9 @@ def main(): print_fixture_literal( driver=get_driver_name(), kek=kek, - encapsulation=bytes.fromhex(key.encapsulated_bytes.value), + encapsulation=key.encapsulated_bytes, metadata=key.metadata, - encrypted=bytes.fromhex(encrypted.value), + encrypted=encrypted, value=string_to_encrypt, ) finally: diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index f64accbb8..cafd8d893 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -102,8 +102,8 @@ def test_fixed_iv_produces_identical_ciphertext(self): iv = bytes(range(12)) value = types.CypherString("hello world") - first = driver.encrypt_to_bytes(value, key_alias="k1", fixed_iv=iv) - second = driver.encrypt_to_bytes(value, key_alias="k1", fixed_iv=iv) + first = driver.encrypt_to_bytes(value, key_alias="k1", iv=iv) + second = driver.encrypt_to_bytes(value, key_alias="k1", iv=iv) self.assertEqual(first, second) @@ -169,7 +169,7 @@ def test_imported_key_decrypts_with_a_fixed_kek(self): kek = bytes(range(32)) driver_1 = self._new_driver( - profiles=({"name": "fx", "fixed_kek": kek},) + profiles=({"name": "fx", "kek": kek},) ) key = driver_1.create_encapsulated_key("k1", profile_name="fx") encrypted = driver_1.encrypt_to_bytes( @@ -179,7 +179,7 @@ def test_imported_key_decrypts_with_a_fixed_kek(self): driver_1.close() driver_2 = self._new_driver( - profiles=({"name": "fx", "fixed_kek": kek},) + profiles=({"name": "fx", "kek": kek},) ) driver_2.import_encapsulated_key( "k1", key.encapsulated_bytes, key.metadata, profile_name="fx" @@ -194,12 +194,12 @@ def test_encrypts_to_known_bytes(self): profiles=( { "name": DETERMINISTIC_PROFILE_NAME, - "fixed_kek": DETERMINISTIC_KEK, + "kek": DETERMINISTIC_KEK, }, ) ) driver.import_encapsulated_key( - "k", types.CypherBytes(DETERMINISTIC_ENCAPSULATION), + "k", DETERMINISTIC_ENCAPSULATION, DETERMINISTIC_KEY_METADATA, profile_name=DETERMINISTIC_PROFILE_NAME ) @@ -208,11 +208,9 @@ def test_encrypts_to_known_bytes(self): with self.subTest(value=case.value): encrypted = driver.encrypt_to_bytes( case.value, profile_name=DETERMINISTIC_PROFILE_NAME, - key_alias="k", fixed_iv=case.iv, aad=case.aad - ) - self.assertEqual( - encrypted, types.CypherBytes(case.encrypted) + key_alias="k", iv=case.iv, aad=case.aad ) + self.assertEqual(encrypted, case.encrypted) def test_decrypts_values_produced_by_other_drivers(self): for case in DECRYPT_INTEROP_TEST_CASES: @@ -221,17 +219,17 @@ def test_decrypts_values_produced_by_other_drivers(self): profiles=( { "name": INTEROP_PROFILE_NAME, - "fixed_kek": case.kek, + "kek": case.kek, }, ) ) driver.import_encapsulated_key( - "k", types.CypherBytes(case.encapsulation), case.metadata, + "k", case.encapsulation, case.metadata, profile_name=INTEROP_PROFILE_NAME ) decrypted = driver.decrypt( - types.CypherBytes(case.encrypted), use_persisted_aad=True + case.encrypted, use_persisted_aad=True ) self.assertEqual( From b7ed9b97d48bed9c23c79a6bfc75e1d2b3ea9d05 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Thu, 20 Aug 2026 13:07:46 +0100 Subject: [PATCH 20/24] Regenerate deterministic fixtures for the sorted metadata ordering (ADR PR #131) The .NET backend now writes Encrypted-structure metadata keys in ascending ordinal order per the ADR's new ordering requirement, so the byte-exact deterministic fixtures need regenerating against it. Freshly random KEK/DEK this run, so every value changed, not just the metadata ordering. --- .../deterministic_fixtures.py | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/stub/property_encryption/deterministic_fixtures.py b/tests/stub/property_encryption/deterministic_fixtures.py index 357af4e61..82485cce5 100644 --- a/tests/stub/property_encryption/deterministic_fixtures.py +++ b/tests/stub/property_encryption/deterministic_fixtures.py @@ -7,15 +7,15 @@ DETERMINISTIC_PROFILE_NAME = "deterministic" DETERMINISTIC_KEK = bytes.fromhex( - "a5be5a4e954ce4bad98c9fda44d6888c" - "26e811b64daffec8918676f3ebf6220e" + "f0de94eb5a2d4da6f17ea74b14e9e556" + "d367cb22b053e01798aa2677bfcf5761" ) DETERMINISTIC_ENCAPSULATION = bytes.fromhex( - "f1b17045c1163d57cd611a26149439a8" - "9ba087d37a4d4aa77dc6087e06c6f7fd" - "eac9827f50e93a5361f6d5923bf2923b" + "9e1f562dee78c6c2d47f4378d2949774" + "c3a56339b824abaf276c4ca7fcf5a8cd" + "63976ae348104d6757b9e419bf9ea325" ) -DETERMINISTIC_KEY_METADATA = {"iv": "U81P4B2CvQt2ykg3"} +DETERMINISTIC_KEY_METADATA = {"iv": "P02Pc7vInYIQ7k93"} @dataclass(frozen=True) @@ -34,10 +34,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc11aaf92b0356bb039bb881e09c11" - "6a9ef99687424f4f4c45414e0100a286" - "6b65795f69648130826976cc0c000102" - "030405060708090a0b" + "63cc11877fe22670d0d3433e2a9c4dd5" + "fd17994b87424f4f4c45414e0100a282" + "6976cc0c000102030405060708090a0b" + "866b65795f69648130" ), ), DeterministicFixture( @@ -47,10 +47,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc150eb3e58bb9d7ffec21f8dbb7ec" - "c8d41a9d0fd4b4b787494e5445474552" - "0100a2866b65795f69648130826976cc" - "0c0c0d0e0f1011121314151617" + "63cc153022e4a29e68285f6fddac8604" + "5e26b63ba5da995087494e5445474552" + "0100a2826976cc0c0c0d0e0f10111213" + "14151617866b65795f69648130" ), ), DeterministicFixture( @@ -60,10 +60,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc19a835184ebf41b0e024656da074" - "b80d02379b4fd866290068eb85464c4f" - "41540100a2866b65795f696481308269" - "76cc0c18191a1b1c1d1e1f20212223" + "63cc1959a942a76621fe2aa1f2d388ab" + "4e91010e4b39b48520328e9585464c4f" + "41540100a2826976cc0c18191a1b1c1d" + "1e1f20212223866b65795f69648130" ), ), DeterministicFixture( @@ -73,11 +73,11 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1c46d9914fd25236d444f2a0f59e" - "a321611003d9b08f79acaeb1975e6a86" - "535452494e470100a2866b65795f6964" - "8130826976cc0c2425262728292a2b2c" - "2d2e2f" + "63cc1c19b0e5f67ee23e78eb73899546" + "4420a6fd3626fc052501e325cee12586" + "535452494e470100a2826976cc0c2425" + "262728292a2b2c2d2e2f866b65795f69" + "648130" ), ), DeterministicFixture( @@ -87,10 +87,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1585b226048348bcbc289d6e46cf" - "0d7f1fb9d0e105188542595445530100" - "a2866b65795f69648130826976cc0c30" - "3132333435363738393a3b" + "63cc1529ea2acd117b82841138917fc8" + "9cdf15cb4f0d809d8542595445530100" + "a2826976cc0c30313233343536373839" + "3a3b866b65795f69648130" ), ), DeterministicFixture( @@ -100,10 +100,10 @@ class DeterministicFixture: ), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1340f23b075078ae0cceccacb3e5" - "1073a65227ef844c4953540100a2866b" - "65795f69648130826976cc0c3c3d3e3f" - "4041424344454647" + "63cc13b5011505031e789718bd92136f" + "766baa191036844c4953540100a28269" + "76cc0c3c3d3e3f404142434445464786" + "6b65795f69648130" ), ), DeterministicFixture( @@ -114,15 +114,15 @@ class DeterministicFixture: aad=types.CypherString("row-42"), encrypted=bytes.fromhex( "01b6658d64657465726d696e69737469" - "63cc1ae485f081fdbbc772219eb19708" - "193afbc72f29d7e958caf65f1f865354" - "52494e470100a5866b65795f69648130" - "826976cc0c48494a4b4c4d4e4f505152" - "5383616164cc0786726f772d3432d019" - "6161645f656e636f64696e675f736368" - "656d655f6d616a6f7201d0196161645f" - "656e636f64696e675f736368656d655f" - "6d696e6f7200" + "63cc1a3a8af0d3820a0a549d75e42e59" + "6a18ff85ee74fb51dce4bc0300865354" + "52494e470100a583616164cc0786726f" + "772d3432d0196161645f656e636f6469" + "6e675f736368656d655f6d616a6f7201" + "d0196161645f656e636f64696e675f73" + "6368656d655f6d696e6f7200826976cc" + "0c48494a4b4c4d4e4f50515253866b65" + "795f69648130" ), ), ] From 8a23b4e435a613d317b23f4a3bcda2f0ab3e9911 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 24 Aug 2026 10:38:15 +0100 Subject: [PATCH 21/24] Add an explicit key_id to ImportEncapsulatedKey The imported key's id was previously left to the backend's own id-generation scheme, which byte-exact encryption tests silently depended on matching. Java's scheme doesn't match .NET's, breaking cross-driver replay of fixtures without hardcoding the id (Dmitriy's PR #727 review). Making key_id explicit removes the hidden coupling. --- nutkit/frontend/driver.py | 6 +++--- nutkit/protocol/requests.py | 7 ++++++- tests/stub/property_encryption/deterministic_fixtures.py | 1 + .../stub/property_encryption/test_property_encryption.py | 8 +++++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/nutkit/frontend/driver.py b/nutkit/frontend/driver.py index 108c27ba7..42da885f2 100644 --- a/nutkit/frontend/driver.py +++ b/nutkit/frontend/driver.py @@ -238,10 +238,10 @@ def create_encapsulated_key(self, alias, *, profile_name=None): raise Exception(f"Should be EncapsulatedKey but was: {res}") return res - def import_encapsulated_key(self, alias, encapsulation, metadata, *, - profile_name=None): + def import_encapsulated_key(self, key_id, alias, encapsulation, metadata, + *, profile_name=None): req = protocol.ImportEncapsulatedKey( - self._driver.id, alias, encapsulation.hex(" "), metadata, + self._driver.id, key_id, alias, encapsulation.hex(" "), metadata, profile_name=profile_name ) res = self.send_and_receive(req, allow_resolution=False) diff --git a/nutkit/protocol/requests.py b/nutkit/protocol/requests.py index c7990a03a..329cb37d1 100644 --- a/nutkit/protocol/requests.py +++ b/nutkit/protocol/requests.py @@ -986,6 +986,10 @@ class ImportEncapsulatedKey: The backend should respond with an EncapsulatedKey or an Error response. :param driver_id: The id of the driver to import the key into. + :param key_id: The id to store the imported key under. Explicit so the + key id recorded in the Encrypted structure's metadata is predictable + regardless of the backend repository's own id-generation scheme, + which byte-exact encryption tests depend on. :param alias: The alias to bind to the imported key. :param encapsulation: The encapsulated (wrapped) data encryption key bytes. @@ -995,9 +999,10 @@ class ImportEncapsulatedKey: key into, or None to use the sole configured profile. """ - def __init__(self, driver_id, alias, encapsulation, metadata, + def __init__(self, driver_id, key_id, alias, encapsulation, metadata, profile_name=None): self.driverId = driver_id + self.keyId = key_id self.alias = alias self.encapsulation = encapsulation self.metadata = metadata diff --git a/tests/stub/property_encryption/deterministic_fixtures.py b/tests/stub/property_encryption/deterministic_fixtures.py index 82485cce5..da27dd236 100644 --- a/tests/stub/property_encryption/deterministic_fixtures.py +++ b/tests/stub/property_encryption/deterministic_fixtures.py @@ -16,6 +16,7 @@ "63976ae348104d6757b9e419bf9ea325" ) DETERMINISTIC_KEY_METADATA = {"iv": "P02Pc7vInYIQ7k93"} +DETERMINISTIC_KEY_ID = "0" @dataclass(frozen=True) diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index cafd8d893..53e7ffd10 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -10,6 +10,7 @@ from tests.stub.property_encryption.deterministic_fixtures import ( DETERMINISTIC_ENCAPSULATION, DETERMINISTIC_KEK, + DETERMINISTIC_KEY_ID, DETERMINISTIC_KEY_METADATA, DETERMINISTIC_PROFILE_NAME, DETERMINISTIC_TEST_CASES, @@ -182,7 +183,8 @@ def test_imported_key_decrypts_with_a_fixed_kek(self): profiles=({"name": "fx", "kek": kek},) ) driver_2.import_encapsulated_key( - "k1", key.encapsulated_bytes, key.metadata, profile_name="fx" + key.id, "k1", key.encapsulated_bytes, key.metadata, + profile_name="fx" ) decrypted = driver_2.decrypt(encrypted, use_persisted_aad=True) @@ -199,7 +201,7 @@ def test_encrypts_to_known_bytes(self): ) ) driver.import_encapsulated_key( - "k", DETERMINISTIC_ENCAPSULATION, + DETERMINISTIC_KEY_ID, "k", DETERMINISTIC_ENCAPSULATION, DETERMINISTIC_KEY_METADATA, profile_name=DETERMINISTIC_PROFILE_NAME ) @@ -224,7 +226,7 @@ def test_decrypts_values_produced_by_other_drivers(self): ) ) driver.import_encapsulated_key( - "k", case.encapsulation, case.metadata, + "0", "k", case.encapsulation, case.metadata, profile_name=INTEROP_PROFILE_NAME ) From beb5d5a3abd2fe9c9b8dbb2c128f4fe44999cf1d Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 24 Aug 2026 10:43:56 +0100 Subject: [PATCH 22/24] Replace the cross-driver decrypt-interop test with a deterministic decrypt test The interop test/generator asked every driver team to run a script and paste a fixture entry by hand, which drifted (stale entries after format changes) and needed hand-verification each time. Replace it with test_decrypts_known_bytes, the decrypt-side mirror of test_encrypts_to_known_bytes: it shares the same DETERMINISTIC_TEST_CASES fixture data, importing the pinned key and decrypting each case's known bytes back to the known value. Cross-driver confidence now comes from every driver decrypting the same hand-computed vectors, not from a generated-and-pasted exchange. --- .../decrypt_interop_fixtures.py | 78 -------------- .../generate_decrypt_interop_fixture.py | 101 ------------------ .../test_property_encryption.py | 44 +++----- 3 files changed, 17 insertions(+), 206 deletions(-) delete mode 100644 tests/stub/property_encryption/decrypt_interop_fixtures.py delete mode 100644 tests/stub/property_encryption/generate_decrypt_interop_fixture.py diff --git a/tests/stub/property_encryption/decrypt_interop_fixtures.py b/tests/stub/property_encryption/decrypt_interop_fixtures.py deleted file mode 100644 index 26d50e600..000000000 --- a/tests/stub/property_encryption/decrypt_interop_fixtures.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Shared cross-driver fixture list for the decrypt-interop check. - -Each entry was produced by one driver team's own implementation, using -generate_decrypt_interop_fixture.py against their own backend. Every driver's -test suite decrypts every entry here, proving it can read values encrypted by -every other driver. - -Add your own entry by running that script and pasting its output below. -""" - -from dataclasses import dataclass - -import nutkit.protocol as types - -# don't change this or the interop decryption test will break -INTEROP_PROFILE_NAME = "interop" - - -@dataclass(frozen=True) -class DecryptInteropFixture: - driver: str - kek: bytes - encapsulation: bytes - metadata: dict - encrypted: bytes - value: object - - -DECRYPT_INTEROP_TEST_CASES = [ - DecryptInteropFixture( - driver="dotnet", - kek=bytes.fromhex( - "1974e1620d8d540ffba202d9eeb616f4" - "266a731f3eadd3637f25c71bb96ad024" - ), - encapsulation=bytes.fromhex( - "014603135847721b9f3a1b1089ec98712" - "1e03c8d703df8c35b7ca95f8893c84f6f" - "e23c661aae26ef78bc9b1b6bb06905" - ), - metadata={"iv": "nJvPChdeMDkE/FDM"}, - encrypted=bytes.fromhex( - "01b66587696e7465726f70cc24eab4839" - "fe33026669beb1413b7a3581cb32edae0" - "1e2c94e41e6e4ed741e6302df4da42208" - "6535452494e470100a5866b65795f6964" - "8130826976cc0ca3feff5e0278a951c49" - "0a11483616164cc00d0126161645f7072" - "6f746f636f6c5f6d616a6f7201d012616" - "1645f70726f746f636f6c5f6d696e6f72" - "00" - ), - value=types.CypherString("hello from dotnet!"), - ), - DecryptInteropFixture( - driver="javascript", - kek=bytes.fromhex( - "ac4828b563d2dd626d34214c3dcd8168" - "ed77c5ed9620b383e4b63665286302f9" - ), - encapsulation=bytes.fromhex( - "4320112f90e2228f4dabffdc82c904bf" - "ff33eb70b4b162354474cb6a389717e9" - "dfe3077d9255913ee6d0e30f2a9f55f0" - ), - metadata={"iv": "lL9ga7QC9WWcKXH6"}, - encrypted=bytes.fromhex( - "01b66587696e7465726f70cc28e4d50c" - "776bdf240ccb0afd823376dfda1b55ba" - "682601933fe5fc15a9276dcdd20e2cda" - "2768236a1686535452494e470100a282" - "6976cc0c343f2b095b3385d0d7adf455" - "866b65795f69648130" - ), - value=types.CypherString("hello from javascript!"), - ), -] diff --git a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py b/tests/stub/property_encryption/generate_decrypt_interop_fixture.py deleted file mode 100644 index 9c69722e3..000000000 --- a/tests/stub/property_encryption/generate_decrypt_interop_fixture.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Generates an entry for the fixture list in decrypt_interop_fixtures.py. - -Make sure the backend is running, then run the script. The script connects to -the backend and pretends to be starting a test, and creates a driver. The -connection details are nonsense because it's not actually going to try to -connect to a database, just using the encryption property. - -If your backend is passing the encryption stub tests then this script will -work, and it will print out an entry for you to paste into the FIXTURES list -in decrypt_interop_fixtures.py. The actual test in test_property_encryption.py -picks it up from there. - -Run as a module from the repo root with your backend already listening on -:9876: - - cd ~/dev/testkit # repo root - python3 -m tests.stub.property_encryption.generate_decrypt_interop_fixture - -""" - -import secrets - -import nutkit.protocol as types -from nutkit.frontend import Driver -from tests.shared import ( - get_driver_name, - new_backend, -) -from tests.stub.property_encryption.decrypt_interop_fixtures import ( - INTEROP_PROFILE_NAME, -) - - -def main(): - backend = new_backend() - try: - backend.send_and_receive(types.GetFeatures()) - backend.send_and_receive( - types.StartTest("generate_decrypt_interop_fixture") - ) - - string_to_encrypt = f"hello from {get_driver_name()}!" - kek = secrets.token_bytes(32) - value = types.CypherString(string_to_encrypt) - auth = types.AuthorizationToken( - "basic", principal="neo4j", credentials="pass" - ) - driver = Driver( - backend, "bolt://localhost:9999", auth, - property_encryption_profiles=[ - {"name": INTEROP_PROFILE_NAME, "kek": kek} - ], - ) - try: - key = driver.create_encapsulated_key( - "k", profile_name=INTEROP_PROFILE_NAME - ) - encrypted = driver.encrypt_to_bytes( - value, profile_name=INTEROP_PROFILE_NAME, key_alias="k" - ) - finally: - driver.close() - - print_fixture_literal( - driver=get_driver_name(), - kek=kek, - encapsulation=key.encapsulated_bytes, - metadata=key.metadata, - encrypted=encrypted, - value=string_to_encrypt, - ) - finally: - backend.close() - - -def print_fixture_literal(*, driver, kek, encapsulation, metadata, encrypted, - value): - metadata_literal = ", ".join( - f'"{key}": "{val}"' for key, val in metadata.items() - ) - print(" DecryptInteropFixture(") - print(f' driver="{driver}",') - print_bytes_literal("kek", kek) - print_bytes_literal("encapsulation", encapsulation) - print(f" metadata={{{metadata_literal}}},") - print_bytes_literal("encrypted", encrypted) - print(f' value=types.CypherString("{value}"),') - print(" ),") - - -def print_bytes_literal(field, value): - print(f" {field}=bytes.fromhex(") - hex_string = value.hex() - for i in range(0, len(hex_string), 32): - print(f' "{hex_string[i:i + 32]}"') - print(" ),") - - -if __name__ == "__main__": - main() diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index 53e7ffd10..a527e8de1 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -3,10 +3,6 @@ import nutkit.protocol as types from nutkit.frontend import Driver from tests.shared import TestkitTestCase -from tests.stub.property_encryption.decrypt_interop_fixtures import ( - DECRYPT_INTEROP_TEST_CASES, - INTEROP_PROFILE_NAME, -) from tests.stub.property_encryption.deterministic_fixtures import ( DETERMINISTIC_ENCAPSULATION, DETERMINISTIC_KEK, @@ -214,30 +210,24 @@ def test_encrypts_to_known_bytes(self): ) self.assertEqual(encrypted, case.encrypted) - def test_decrypts_values_produced_by_other_drivers(self): - for case in DECRYPT_INTEROP_TEST_CASES: - with self.subTest(driver=case.driver): - driver = self._new_driver( - profiles=( - { - "name": INTEROP_PROFILE_NAME, - "kek": case.kek, - }, - ) - ) - driver.import_encapsulated_key( - "0", "k", case.encapsulation, case.metadata, - profile_name=INTEROP_PROFILE_NAME - ) + def test_decrypts_known_bytes(self): + driver = self._new_driver( + profiles=( + { + "name": DETERMINISTIC_PROFILE_NAME, + "kek": DETERMINISTIC_KEK, + }, + ) + ) + driver.import_encapsulated_key( + DETERMINISTIC_KEY_ID, "k", DETERMINISTIC_ENCAPSULATION, + DETERMINISTIC_KEY_METADATA, + profile_name=DETERMINISTIC_PROFILE_NAME + ) + for case in DETERMINISTIC_TEST_CASES: + with self.subTest(value=case.value): decrypted = driver.decrypt( case.encrypted, use_persisted_aad=True ) - - self.assertEqual( - decrypted, case.value, - "Could not decrypt value encrypted by driver: " - f"{case.driver}" - ) - driver.close() - self._driver = None + self.assertEqual(decrypted, case.value) From 888085e294041bc864a8af56b7a9fcb0b3b1deaf Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 24 Aug 2026 11:16:36 +0100 Subject: [PATCH 23/24] Narrow the EncapsulatedKey response to the id and alias The encapsulation and its key-encapsulation metadata are not the driver's to report back on DEK creation: the caller supplied the KeyEncapsulationService and repository that produced them (Dmitriy's PR #727 review). test_imported_key_decrypts_with_a_fixed_kek was the only test reading those fields off a create response. It now imports the same pinned fixture key into both drivers instead, which still proves the kek is honoured: were it ignored, the two drivers would decapsulate to different DEKs and the decrypt would fail on the GCM tag. Extracted the resulting shared setup into _new_deterministic_driver, which the two byte-exact tests were already duplicating. --- nutkit/protocol/responses.py | 7 +-- .../test_property_encryption.py | 62 +++++++------------ 2 files changed, 24 insertions(+), 45 deletions(-) diff --git a/nutkit/protocol/responses.py b/nutkit/protocol/responses.py index bf187900d..d9f2e5846 100644 --- a/nutkit/protocol/responses.py +++ b/nutkit/protocol/responses.py @@ -369,14 +369,13 @@ class EncapsulatedKey: """ An encapsulated data encryption key. - Sent in response to a CreateEncapsulatedKey request. + Sent in response to a CreateEncapsulatedKey or ImportEncapsulatedKey + request. """ - def __init__(self, id, alias, encapsulatedBytes, metadata): + def __init__(self, id, alias): self.id = id self.alias = alias - self.encapsulated_bytes = bytes.fromhex(encapsulatedBytes) - self.metadata = metadata class Result: diff --git a/tests/stub/property_encryption/test_property_encryption.py b/tests/stub/property_encryption/test_property_encryption.py index a527e8de1..ae257b7f2 100644 --- a/tests/stub/property_encryption/test_property_encryption.py +++ b/tests/stub/property_encryption/test_property_encryption.py @@ -37,6 +37,22 @@ def _new_driver(self, profiles=("default",)): ) return self._driver + def _new_deterministic_driver(self): + driver = self._new_driver( + profiles=( + { + "name": DETERMINISTIC_PROFILE_NAME, + "kek": DETERMINISTIC_KEK, + }, + ) + ) + driver.import_encapsulated_key( + DETERMINISTIC_KEY_ID, "k", DETERMINISTIC_ENCAPSULATION, + DETERMINISTIC_KEY_METADATA, + profile_name=DETERMINISTIC_PROFILE_NAME + ) + return driver + def test_round_trips_a_single_value(self): driver = self._new_driver() driver.create_encapsulated_key("k1") @@ -163,44 +179,20 @@ def test_key_manager_raises_when_ambiguous_and_no_profile_given(self): driver.create_encapsulated_key("k1") def test_imported_key_decrypts_with_a_fixed_kek(self): - kek = bytes(range(32)) - - driver_1 = self._new_driver( - profiles=({"name": "fx", "kek": kek},) - ) - key = driver_1.create_encapsulated_key("k1", profile_name="fx") + driver_1 = self._new_deterministic_driver() encrypted = driver_1.encrypt_to_bytes( types.CypherString("hello world"), - profile_name="fx", key_alias="k1" + profile_name=DETERMINISTIC_PROFILE_NAME, key_alias="k" ) driver_1.close() - driver_2 = self._new_driver( - profiles=({"name": "fx", "kek": kek},) - ) - driver_2.import_encapsulated_key( - key.id, "k1", key.encapsulated_bytes, key.metadata, - profile_name="fx" - ) - + driver_2 = self._new_deterministic_driver() decrypted = driver_2.decrypt(encrypted, use_persisted_aad=True) self.assertEqual(decrypted, types.CypherString("hello world")) def test_encrypts_to_known_bytes(self): - driver = self._new_driver( - profiles=( - { - "name": DETERMINISTIC_PROFILE_NAME, - "kek": DETERMINISTIC_KEK, - }, - ) - ) - driver.import_encapsulated_key( - DETERMINISTIC_KEY_ID, "k", DETERMINISTIC_ENCAPSULATION, - DETERMINISTIC_KEY_METADATA, - profile_name=DETERMINISTIC_PROFILE_NAME - ) + driver = self._new_deterministic_driver() for case in DETERMINISTIC_TEST_CASES: with self.subTest(value=case.value): @@ -211,19 +203,7 @@ def test_encrypts_to_known_bytes(self): self.assertEqual(encrypted, case.encrypted) def test_decrypts_known_bytes(self): - driver = self._new_driver( - profiles=( - { - "name": DETERMINISTIC_PROFILE_NAME, - "kek": DETERMINISTIC_KEK, - }, - ) - ) - driver.import_encapsulated_key( - DETERMINISTIC_KEY_ID, "k", DETERMINISTIC_ENCAPSULATION, - DETERMINISTIC_KEY_METADATA, - profile_name=DETERMINISTIC_PROFILE_NAME - ) + driver = self._new_deterministic_driver() for case in DETERMINISTIC_TEST_CASES: with self.subTest(value=case.value): From 113fdccc4a08ee14e84d8a66e8513be52d33a626 Mon Sep 17 00:00:00 2001 From: Richard Irons Date: Mon, 24 Aug 2026 11:16:44 +0100 Subject: [PATCH 24/24] Pin the fixture key id to "testkit-key" and regenerate The key id was previously whatever the backend repository happened to assign its first key ("0" for .NET), which the byte-exact fixtures then silently depended on. Pinning it as an explicit input removes that coupling, so the same fixtures reproduce on any backend. "testkit-key" is deliberately non-numeric and 11 bytes, so it can neither coincide with a backend counter nor share a PackStream string marker with the single-byte id it replaces. Ciphertext bodies are unchanged, as the key id lives in the structure metadata rather than the AAD. --- .../deterministic_fixtures.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/stub/property_encryption/deterministic_fixtures.py b/tests/stub/property_encryption/deterministic_fixtures.py index da27dd236..b86b77a4c 100644 --- a/tests/stub/property_encryption/deterministic_fixtures.py +++ b/tests/stub/property_encryption/deterministic_fixtures.py @@ -16,7 +16,7 @@ "63976ae348104d6757b9e419bf9ea325" ) DETERMINISTIC_KEY_METADATA = {"iv": "P02Pc7vInYIQ7k93"} -DETERMINISTIC_KEY_ID = "0" +DETERMINISTIC_KEY_ID = "testkit-key" @dataclass(frozen=True) @@ -38,7 +38,8 @@ class DeterministicFixture: "63cc11877fe22670d0d3433e2a9c4dd5" "fd17994b87424f4f4c45414e0100a282" "6976cc0c000102030405060708090a0b" - "866b65795f69648130" + "866b65795f69648b746573746b69742d" + "6b6579" ), ), DeterministicFixture( @@ -51,7 +52,8 @@ class DeterministicFixture: "63cc153022e4a29e68285f6fddac8604" "5e26b63ba5da995087494e5445474552" "0100a2826976cc0c0c0d0e0f10111213" - "14151617866b65795f69648130" + "14151617866b65795f69648b74657374" + "6b69742d6b6579" ), ), DeterministicFixture( @@ -64,7 +66,8 @@ class DeterministicFixture: "63cc1959a942a76621fe2aa1f2d388ab" "4e91010e4b39b48520328e9585464c4f" "41540100a2826976cc0c18191a1b1c1d" - "1e1f20212223866b65795f69648130" + "1e1f20212223866b65795f69648b7465" + "73746b69742d6b6579" ), ), DeterministicFixture( @@ -78,7 +81,7 @@ class DeterministicFixture: "4420a6fd3626fc052501e325cee12586" "535452494e470100a2826976cc0c2425" "262728292a2b2c2d2e2f866b65795f69" - "648130" + "648b746573746b69742d6b6579" ), ), DeterministicFixture( @@ -91,7 +94,8 @@ class DeterministicFixture: "63cc1529ea2acd117b82841138917fc8" "9cdf15cb4f0d809d8542595445530100" "a2826976cc0c30313233343536373839" - "3a3b866b65795f69648130" + "3a3b866b65795f69648b746573746b69" + "742d6b6579" ), ), DeterministicFixture( @@ -104,7 +108,8 @@ class DeterministicFixture: "63cc13b5011505031e789718bd92136f" "766baa191036844c4953540100a28269" "76cc0c3c3d3e3f404142434445464786" - "6b65795f69648130" + "6b65795f69648b746573746b69742d6b" + "6579" ), ), DeterministicFixture( @@ -123,7 +128,7 @@ class DeterministicFixture: "d0196161645f656e636f64696e675f73" "6368656d655f6d696e6f7200826976cc" "0c48494a4b4c4d4e4f50515253866b65" - "795f69648130" + "795f69648b746573746b69742d6b6579" ), ), ]