Skip to content

feat(python): open and seal the Go SDK's rows, and wipe plaintext copies - #1513

Draft
yujiyokoo wants to merge 5 commits into
feat/python-sdk-valuesfrom
feat/python-sdk-parity
Draft

yujiyokoo wants to merge 5 commits into
feat/python-sdk-valuesfrom
feat/python-sdk-parity

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

The Python SDK now opens the EQL rows that the Go SDK stored, and seals the same values to the same search terms. EQL (Encrypt Query Language) is CipherStash's set of PostgreSQL types for encrypted values. A new test also checks that the extension wipes its own copies of the plaintext before it frees them.

These tests need no credentials. A test build of the extension takes its keys from a fixed seed, in the same way as the Go SDK's test build.

Changes

  • Test build: the engine crate has a new deterministic-kms feature.
    • _engine._use_deterministic_keys(seed) switches the process to stack-kms's DeterministicSource. The Go SDK's test build uses the same key source.
    • After the switch, the live client refuses to build. So an operation that skips the switch fails its test, and sends no request to ZeroKMS.
    • client::run! runs each operation on whichever client is in place. encrypt, decrypt, and query each write their call once.
  • Go rows: tests_deterministic/test_go_rows.py reads four Go fixtures in packages/eql/tests/encryption/fixtures/. For each one, Python does three things:
    • Python opens the rows, and each value matches exactly. A float's sign and a Decimal's scale count.
    • Python seals the same values. Each stored value matches Go's, except the ciphertext c.
    • Python makes an equality query term for each value. Each one matches the term Go stored.
  • Frozen rows: one of the four fixtures is stored_v4_rows_v1.json. Nothing writes it again. The Go and Rust tests already check it, and now Python does too.
  • Values Python cannot hold: Go can store a year outside 1 to 9999, and a timestamp with nanoseconds. Python's datetime supports neither.
    • decrypt now refuses both with decrypt_not_representable. The message names the one cause that applies.
    • Before this change, a timestamp with nanoseconds was rounded to microseconds. The value then changed, and sealed to another equality term.
  • Wiping plaintext:
    • An opened value now wipes itself when dropped. The wipe covers text, bytes, and every number.
    • decrypt and encrypt now borrow each value, and no longer move it out of its row. A moved value left its bytes in the row's buffer, and the buffer was freed with them.
    • A decrypted row is now built in one buffer. Before, the code relied on an unspecified rustc optimisation to leave no copy behind.
    • A decimal's digits are wiped after each read. They are also wiped after each conversion to a Decimal.
  • Heap hygiene test: crates/engine/src/heap_hygiene.rs installs a spy allocator.
    • The spy fails a test when a block freed during encrypt, query, or decrypt still contains the plaintext.
    • The spy looks for text by a marker, and for a decimal by its digits and its mantissa.
    • A fourth test checks that the spy does fail on a leak of each needle.
    • One free is excluded: vitaminc's aws-lc backend frees an unwiped buffer on every seal. That is vitaminc's to fix.
    • Python's own objects are out of scope. The caller owns them.
  • Tasks and CI:
    • mise run test:deterministic builds the test extension, then runs tests_deterministic/ in its own pytest process.
    • tests-python.yml runs it in a new step, "Python against the Go SDK's rows". The workflow also runs when the Go fixtures change.
    • CI's clippy step now lints every feature.
  • maturin features: a --features flag replaces the features in pyproject.toml. So the test task names pyo3/extension-module again. Without it, maturin 1.9 links the test build to libpython.

Verification

  • mise run test:cargo: all core and extension tests pass, with the 4 hygiene tests.
  • I removed each wipe in turn, and a hygiene test failed each time.
  • mise run test:deterministic: 18 passed.
  • pytest: 353 passed, 25 skipped. The skips are the 23 live tests and 2 gate-probe tests.
  • clippy and cargo fmt --check: no findings, with and without all features.
  • pnpm run test:scripts: 84 of 84 files pass.
  • mypy and pyright: no new findings at the second commit. Later commits changed only two assert lines in Python.
  • Python 3.9: the tests passed on 3.9 up to the third commit. The last two commits ran on 3.14 only. Those two commits changed no Python code.
  • The hygiene test ran on macOS only. CI runs it on Linux, so this PR's job is its first run there.

Related

Refs #1393. Refs #1341.

This PR does not close #1393. These parts of the issue are not in it:

  • A live parity binary: the deterministic test build replaces it. The keys come from a seed, so no credentials are needed. With the same seed, Python can open Go's rows.
  • Fixtures with no Python in the path: a test that does not go through Python checks nothing about the Python SDK.
  • Frozen rows written by Python: Python opens Go's frozen rows instead. Both SDKs now share one stored format.
  • The shared conformance corpus: a later PR adds it, after this stack moves onto next.

Review notes

  • Start with client.rs for the switch, then heap_hygiene.rs.
  • The race between the switch and the first live build is closed in both directions. No test covers it, because it needs two threads at one exact moment.
  • No test covers a Python error on a date or timestamp other than a range error. to_python passes such an error on unchanged.

🤖 Generated with Claude Code

yujiyokoo and others added 5 commits November 3, 2026 02:07
Add a deterministic-kms test feature to the extension. In that build,
_engine._use_deterministic_keys(seed) makes the process take its keys
from stack-kms's DeterministicSource, as the Go SDK's deterministic
guest does. A normal build has neither the feature nor the function.

tests_deterministic/ runs against the EQL rows the Go SDK stored under
that seed: the frozen stored_v4_rows_v1.json, and the ordered,
smallint and ledger fixtures. Python opens Go's rows to the same
values, and seals the same values to byte-identical equality, order
and match terms. Its query terms equal Go's stored terms. Two ledger
rows Python cannot hold are refused with decrypt_not_representable.
mise run test:deterministic runs them in their own pytest process,
and tests-python.yml runs that task with no credentials.

Add a heap-hygiene test to the extension: a spy allocator fails when
a block freed during encrypt or query still contains the plaintext.
It found that the owned copy of a str, which pyo3 makes under
abi3-py39, was freed unwiped. Read now wipes it with zeroize.

The four native-plan fixtures and the shared conformance corpus are
not part of this commit. The fixtures declare plans the Python SDK
cannot express. The corpus needs the stack rebased onto next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuse an opened timestamp with nanoseconds as
decrypt_not_representable. to_python rounded it to microseconds, so
a Go-written value opened changed, and resealed to another equality
term. The ledger test now checks nanoseconds and years on their own:
the old test passed only because those rows also had a year out of
range.

Wipe decrypted plaintext. A Scalar now wipes its text and bytes when
dropped, and to_python borrows it. Decimal digits are wiped on read
and on to_python. The hygiene test now watches decrypt too, and a
decimal by its digits.

The hygiene test now checks that the spy fails on a leak of each
needle. Each test takes one lock before it attaches to Python: one
test could hold the watch lock and wait for the GIL while another
held the GIL and waited for the watch lock.

In the test build, client::shared() refuses after the switch to the
deterministic client, so an operation that skips the check cannot
reach ZeroKMS. use_deterministic takes the build lock. CI's clippy
step lints every feature. test_go_rows.py no longer imports from
conftest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Scalar now wipes every variant when dropped, not only text and
bytes: integers, floats, dates, timestamps and decimal mantissas sit
inline in a row's buffer. decrypt also stops moving each value out
of its row. A moved value left its bytes in the old slot, and the
buffer was freed with them. to_python now borrows the value, so each
one is wiped where it lies. The hygiene test now watches a decimal's
mantissa as the engine's i128, and fails without either fix.

decrypt_not_representable now names the one cause that applies: a
year outside 1 to 9999, or a timestamp with nanoseconds.

In the test build, shared() checks for the deterministic client
again after it takes the build lock. A thread that waited for the
lock during the switch built a live client.

A --features flag on maturin's command line replaces the features in
pyproject.toml. test:deterministic now names pyo3/extension-module
again. Maturin 1.10 and later set PYO3_BUILD_EXTENSION_MODULE, but
pyproject.toml allows 1.9, where the test build linked libpython.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1393)

encrypt, decrypt and query each wrote their call twice: once for the
deterministic test client, once for the live one. client::run! now
takes the call once and compiles it for both, so the two cannot
drift. The clients have different key source types, so a function
that returned the current client would still need two calls.

encrypt now borrows each column, as decrypt does. A moved passthrough
value left its bytes in the row's buffer when the buffer was freed.

A decrypted row is now written into one buffer and moved whole. It
was a Vec<Option<Scalar>> collected into a Vec<Scalar>, which left
no plaintext only because rustc reused the buffer in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decrypt chose the cause of decrypt_not_representable from the value
before to_python ran, with its own copy of the nanosecond rule. Any
other Python error on a date or timestamp was reported as a year out
of range, and the real error was lost.

to_python now returns the cause itself, as Unfit::Nanoseconds or
Unfit::Year. A year is reported only for datetime's own range errors:
ValueError from fromordinal, OverflowError from timedelta or the
addition. Every other error is Python's own and is raised as it is.

The comment in crates/engine/Cargo.toml now names both places that
turn on extension-module: pyproject.toml, and the test:deterministic
task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9ea5ac8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@blacksmith-sh

blacksmith-sh Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
test_encrypt_live/test_a_sqlalchemy_class_is_built_without_its_ignored_relationship View Logs
test_encrypt_live/
test_rows_read_back_with_dict_row_decrypt_into_the_models[SqlAlchemyUser]
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

This branch has not been deployed

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

1 participant