Skip to content

Testkit tests for property encryption - #727

Draft
RichardIrons-neo4j wants to merge 21 commits into
6.xfrom
feat/property-encryption
Draft

Testkit tests for property encryption#727
RichardIrons-neo4j wants to merge 21 commits into
6.xfrom
feat/property-encryption

Conversation

@RichardIrons-neo4j

@RichardIrons-neo4j RichardIrons-neo4j commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Property encryption

Feature flag: Feature.API_PROPERTY_ENCRYPTION.

There is a python script at tests/stub/property_encryption/generate_decrypt_interop_fixture.py, which will communicate with the backend to generate the a test case for the cross-driver decryption test. If the stub tests are passing then this script will work.

Protocol additions

Throughout this doc, bytes means a bare space-separated lower-case hex string on the wire (e.g. "0a 1b 2c"), the same convention CypherVector's data field uses — not a CypherBytes Cypher-value envelope. This applies to kek, iv, encapsulation, encryptedBytes, and encapsulatedBytes below, since none of them are themselves a Cypher property value. value and aad are the only fields that carry real property values, so they stay full <any Cypher value> envelopes.

NewDriver

NewDriver gains one optional field:

propertyEncryptionProfiles: [{ name: string, kek: bytes | null }]

Each entry configures one property-encryption profile on the driver. The profile is of the Envelope kind, backed by a backend-owned fixture KeyEncapsulationService/EncapsulatedKeyRepository pair; the backend plays the role of the user, who in a real application owns and supplies these objects directly. Profile names must be unique within a driver. If the field is omitted, no encryption profiles are configured.

kek, when given, constructs the profile's fixture key-encapsulation service with that exact key instead of a random one. This exists solely to support the deterministic and cross-driver decrypt tests; ordinary tests omit it.

EncryptToBytes

EncryptToBytes {
  driverId: string
  value: <any Cypher value>
  aad: <any Cypher value> | null
  profileName: string | null   # null => use the sole configured profile
  keyAlias: string | null      # exactly one of keyAlias/keyId must be set
  keyId: string | null
  iv: bytes | null             # null => random IV as normal
}
-> EncryptedValue { encryptedBytes: bytes }
-> Error

Encrypts value, binding aad when supplied. profileName may be omitted only when exactly one profile is configured; see the ambiguous-profile scenario below.

iv, when given, is the exact 12-byte IV the driver must use for this one encrypt call. The backend raises if the IV is not exactly 12 bytes or the operation doesn't consume it. With the data-encryption key pinned via ImportEncapsulatedKey and the IV pinned here, an encrypt is fully deterministic, so tests can assert byte-exact ciphertext instead of only round-tripping. Like kek, this exists solely for tests that ask for determinism; ordinary tests omit it. test_encrypts_to_known_bytes uses it to check the driver's encryption output byte-for-byte against the known-answer fixtures in deterministic_fixtures.py (initially generated with the .NET backend).

Decrypt

Decrypt {
  driverId: string
  value: bytes                 # as returned by EncryptToBytes
  aad: <any Cypher value> | null
  usePersistedAad: bool        # exactly one of aad/usePersistedAad must be set
}
-> DecryptedValue { decryptedValue: <any Cypher value> }
-> Error

The profile is resolved from the encrypted bytes: each encrypted value records which profile produced it, so the caller does not name one.

CreateEncapsulatedKey

CreateEncapsulatedKey {
  driverId: string
  alias: string
  profileName: string | null   # null => use the sole configured profile
}
-> EncapsulatedKey { id: string, alias: string, encapsulatedBytes: bytes, metadata: map<string,string> }
-> Error

Generates a fresh data-encryption key through the profile's KeyEncapsulationService and registers it in the profile's EncapsulatedKeyRepository under alias. A key must exist under a given alias before that alias can be used for encryption.

ImportEncapsulatedKey

ImportEncapsulatedKey {
  driverId: string
  alias: string
  encapsulation: bytes           # the wrapped-DEK bytes
  metadata: map<string,string>   # KES metadata for the encapsulation (e.g. the wrap IV)
  profileName: string
}
-> EncapsulatedKey { id: string, alias: string, encapsulatedBytes: bytes, metadata: map<string,string> }
-> Error

Seeds a profile's repository with a pre-made encapsulated key directly, skipping the key-generation step CreateEncapsulatedKey performs, since the caller already has an encapsulation from elsewhere (a fixture, or a prior CreateEncapsulatedKey response). This corresponds to what a real user would do to import a key obtained elsewhere: call their own repository's save method directly, without going through the driver's PropertyEncryption API at all. Unlike the other messages here, profileName is mandatory; there is no sole-profile default.

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.
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.
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.
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.
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.
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.

@MaxAake MaxAake left a comment

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.

💬

Comment thread tests/stub/property_encryption/test_property_encryption.py Outdated
Comment thread tests/stub/property_encryption/test_property_encryption.py
MaxAake and others added 12 commits August 17, 2026 12:19
…ation

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).
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).
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.
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.
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.
The suite only ever used use_persisted_aad on values encrypted without
AAD, so a driver that silently ignores the persisted AAD still passed.
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.
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.
…DR 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.
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.

Comment on lines +378 to +379
self.encapsulated_bytes = bytes.fromhex(encapsulatedBytes)
self.metadata = metadata

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.


self.assertEqual(decrypted, types.CypherString("hello world"))

def test_encrypts_to_known_bytes(self):

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.

@RichardIrons-neo4j, I have been able to make it work with Java Driver, but only when I hardcode "0" key id on import. I think addressing the following will resolve this: https://github.com/neo4j-drivers/testkit/pull/727/changes#r3824978418

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants