Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8f71634
Added client-side property encryption protocol objects and simple rou…
RichardIrons-neo4j Aug 5, 2026
5953e4d
Fix casing
RichardIrons-neo4j Aug 5, 2026
bb101a5
Strengthen property encryption stub tests
RichardIrons-neo4j Aug 5, 2026
4639707
Add a stub test for decrypting with the wrong AAD
RichardIrons-neo4j Aug 5, 2026
436914a
Add more tests, scraping the barrel of the ADR a bit
RichardIrons-neo4j Aug 5, 2026
559bc30
Add cross-driver decrypt-interop check for property encryption
RichardIrons-neo4j Aug 6, 2026
4eedd40
Derive the fixture generator's encrypted value from the driver name
RichardIrons-neo4j Aug 6, 2026
70fde99
Fix flake8/isort style violations in the decrypt-interop check
RichardIrons-neo4j Aug 6, 2026
e293a25
Skip building the Rust boltstub when TEST_RUSTY_STUB isn't set
RichardIrons-neo4j Aug 8, 2026
430b2e5
add javascript test
MaxAake Aug 17, 2026
fe0a73f
Regenerate the javascript decrypt-interop fixture with HKDF key deriv…
RichardIrons-neo4j Aug 17, 2026
f66641d
Make the fixture generator print lint-clean, paste-ready entries
RichardIrons-neo4j Aug 17, 2026
3f0c717
Add Backend:MockRandom for deterministic property-encryption tests
RichardIrons-neo4j Aug 19, 2026
f49760c
Replace Backend:MockRandom with a per-call fixedIv on EncryptToBytes
RichardIrons-neo4j Aug 19, 2026
4a46206
Add deterministic encryption test with known-answer fixtures
RichardIrons-neo4j Aug 19, 2026
1e4aaf8
Merge remote-tracking branch 'neo4j/6.x' into feat/property-encryption
RichardIrons-neo4j Aug 19, 2026
9b264a1
Test that decrypt honours the persisted AAD on an AAD-bound value
RichardIrons-neo4j Aug 20, 2026
cce49ca
remove number too big for js
RichardIrons-neo4j Aug 20, 2026
642a43d
Regenerate deterministic fixtures for ADR-conformant AAD metadata
RichardIrons-neo4j Aug 20, 2026
0fff674
Rename fixedIv/fixedKek to iv/kek and encode as bare hex on the wire
RichardIrons-neo4j Aug 20, 2026
b7ed9b9
Regenerate deterministic fixtures for the sorted metadata ordering (A…
RichardIrons-neo4j Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion nutkit/frontend/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,13 @@ 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_ = [
self._encryption_profile_wire(p)
for p in property_encryption_profiles
]

req = protocol.NewDriver(
uri, self._auth_token, auth_token_manager_id,
userAgent=user_agent, resolverRegistered=resolver_fn is not None,
Expand All @@ -71,13 +79,24 @@ 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):
raise Exception("Should be Driver but was %s" % res)
self._driver = res
self._closed = False

@staticmethod
def _encryption_profile_wire(profile):
if isinstance(profile, str):
return {"name": profile}
wire = {"name": profile["name"]}
kek = profile.get("kek")
if kek is not None:
wire["kek"] = kek.hex(" ")
return wire

def receive(self, timeout=None, hooks=None, *, allow_resolution):
while True:
res = self._backend.receive(timeout=timeout, hooks=hooks)
Expand Down Expand Up @@ -188,6 +207,48 @@ 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, 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, iv=iv_
)
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.hex(" "), 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 import_encapsulated_key(self, alias, encapsulation, metadata, *,
profile_name=None):
req = protocol.ImportEncapsulatedKey(
self._driver.id, alias, encapsulation.hex(" "), 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)
Expand Down
4 changes: 4 additions & 0 deletions nutkit/protocol/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
111 changes: 111 additions & 0 deletions nutkit/protocol/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -891,3 +894,111 @@ 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.
: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
encryption tests.
"""

def __init__(self, driver_id, value, aad=None, profile_name=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.iv = iv


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


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should add the keyId here as well as the identifier goes into the metadata of the Encrypted Structure and it must be predictable for stub tests asserting on expected encrypted bytes. The message handler should take all of this in import the key directly into its key repository.

self.encapsulation = encapsulation
self.metadata = metadata
self.profileName = profile_name
36 changes: 36 additions & 0 deletions nutkit/protocol/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, encryptedBytes):
self.encrypted_bytes = bytes.fromhex(encryptedBytes)


class DecryptedValue:
"""
The result of decrypting a value with client-side property encryption.

Sent in response to a Decrypt request.
"""

def __init__(self, decryptedValue):
self.decrypted_value = decryptedValue


class EncapsulatedKey:
"""
An encapsulated data encryption key.

Sent in response to a CreateEncapsulatedKey request.
"""

def __init__(self, id, alias, encapsulatedBytes, metadata):
self.id = id
self.alias = alias
self.encapsulated_bytes = bytes.fromhex(encapsulatedBytes)
self.metadata = metadata
Comment on lines +378 to +379

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

encapsulatedBytes and metadata are not public API on DEK creation. I think it would be better if we did not require them here.



class Result:
"""Represents a result instance on the backend."""

Expand Down
Empty file.
78 changes: 78 additions & 0 deletions tests/stub/property_encryption/decrypt_interop_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
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!"),
),
]
Loading