diff --git a/languages/python/Cargo.lock b/languages/python/Cargo.lock index 6122e80a2..5cba58c17 100644 --- a/languages/python/Cargo.lock +++ b/languages/python/Cargo.lock @@ -2228,6 +2228,7 @@ version = "0.1.0" dependencies = [ "pyo3", "stackencrypt-core", + "tokio", ] [[package]] diff --git a/languages/python/crates/core/tests/gate_frame_reader.rs b/languages/python/crates/core/tests/gate_frame_reader.rs new file mode 100644 index 000000000..9d1dbd42e --- /dev/null +++ b/languages/python/crates/core/tests/gate_frame_reader.rs @@ -0,0 +1,202 @@ +//! Gate fact 1 (#1384): code outside the engine can walk a decrypted record +//! frame and recover each field's value kind, with only the public API of +//! `stack-encrypt`'s `dynamic` module. +//! +//! The record is built with `ValueFrame::of`, the same type and writer +//! `dynamic::decrypt` returns its opened records in. This test does not +//! decrypt: it proves the reading half, which is the half the extension owns. + +use stack_encrypt::dynamic::frame::Frame; +use stack_encrypt::dynamic::plaintext::{ + Encrypt, Error, FixedType, Int, Logical, MapSink, Physical, SeqSink, Sink, SourceError, +}; +use stack_encrypt::dynamic::ValueFrame; + +/// A record the way a Python model would describe itself: text, an integer, +/// a date and a passthrough subtree. +struct User; + +impl Encrypt for User { + fn visit(self, sink: S) -> Result { + let mut map = sink.map(Some(4))?; + map.entry("email")? + .scalar(None, Physical::Text("ada@example.com"))?; + map.entry("age")? + .scalar(None, Physical::Int(Int::I64(36)))?; + map.entry("born")? + .scalar(Some(Logical::Date), Physical::Int(Int::I32(-25_567)))?; + map.entry("nickname")?.passthrough(Nickname)?; + map.end() + } +} + +#[derive(Clone)] +struct Nickname; + +impl Encrypt for Nickname { + fn visit(self, sink: S) -> Result { + sink.scalar(None, Physical::Text("ada")) + } +} + +/// What the reader records for one field: its key, the logical type, and the +/// physical kind. Values are not recorded, so the test never copies plaintext. +#[derive(Debug, PartialEq)] +struct Seen { + key: String, + logical: Option, + physical: &'static str, + passthrough: bool, +} + +#[derive(Debug)] +struct ReadError; + +impl Error for ReadError { + fn source(_: SourceError) -> Self { + ReadError + } +} + +/// The reader: a `Sink` written outside the engine crate. +struct Reader<'a> { + seen: &'a mut Vec, + key: Option, + passthrough: bool, +} + +fn physical_name(value: &Physical<'_>) -> &'static str { + match value { + Physical::Null => "null", + Physical::Bool(_) => "bool", + Physical::Int(_) => "int", + Physical::Float(_) => "float", + Physical::Text(_) => "text", + Physical::Bytes(_) => "bytes", + } +} + +impl Sink for Reader<'_> { + type Ok = (); + type Error = ReadError; + type Seq = Self; + type Map = Self; + + fn scalar(self, ty: Option, value: Physical<'_>) -> Result<(), ReadError> { + self.seen.push(Seen { + key: self.key.unwrap_or_default(), + logical: ty.map(|ty| format!("{ty:?}")), + physical: physical_name(&value), + passthrough: self.passthrough, + }); + Ok(()) + } + + fn typed_null(self, _: FixedType) -> Result<(), ReadError> { + self.scalar(None, Physical::Null) + } + + fn seq(self, _: Option) -> Result { + Ok(self) + } + + fn map(self, _: Option) -> Result { + Ok(self) + } + + fn passthrough(self, value: P) -> Result<(), ReadError> { + value.visit(Reader { + seen: self.seen, + key: self.key, + passthrough: true, + }) + } +} + +impl SeqSink for Reader<'_> { + type Ok = (); + type Error = ReadError; + type Element<'s> + = Reader<'s> + where + Self: 's; + + fn element(&mut self) -> Result, ReadError> { + Ok(Reader { + seen: self.seen, + key: self.key.clone(), + passthrough: self.passthrough, + }) + } + + fn end(self) -> Result<(), ReadError> { + Ok(()) + } +} + +impl MapSink for Reader<'_> { + type Ok = (); + type Error = ReadError; + type Entry<'s> + = Reader<'s> + where + Self: 's; + + fn entry(&mut self, key: &str) -> Result, ReadError> { + Ok(Reader { + seen: self.seen, + key: Some(key.to_owned()), + passthrough: self.passthrough, + }) + } + + fn end(self) -> Result<(), ReadError> { + Ok(()) + } +} + +fn read(frame: Frame<'_>) -> Vec { + let mut seen = Vec::new(); + frame + .visit(Reader { + seen: &mut seen, + key: None, + passthrough: false, + }) + .expect("a validated frame walks without error"); + seen +} + +fn seen(key: &str, logical: Option<&str>, physical: &'static str, passthrough: bool) -> Seen { + Seen { + key: key.to_owned(), + logical: logical.map(str::to_owned), + physical, + passthrough, + } +} + +#[test] +fn an_outside_sink_recovers_each_field_kind_from_a_record_frame() { + let record = ValueFrame::of(User).expect("the record is in the value model"); + let frame = Frame::parse(record.risky_ref()).expect("a ValueFrame is one frame"); + + assert_eq!( + read(frame), + vec![ + seen("email", None, "text", false), + seen("age", None, "int", false), + seen("born", Some("Date"), "int", false), + seen("nickname", None, "text", true), + ], + ); +} + +#[test] +fn malformed_frame_bytes_are_refused_before_any_walk() { + let record = ValueFrame::of(User).expect("the record is in the value model"); + let bytes = record.risky_ref(); + let truncated = &bytes[..bytes.len() - 1]; + + assert!(Frame::parse(truncated).is_err()); +} diff --git a/languages/python/crates/engine/Cargo.toml b/languages/python/crates/engine/Cargo.toml index f4c9d1cc9..8b86ba296 100644 --- a/languages/python/crates/engine/Cargo.toml +++ b/languages/python/crates/engine/Cargo.toml @@ -28,3 +28,11 @@ stackencrypt-core = { path = "../core" } # binary has to link it or fails with undefined `_Py*` symbols. maturin turns # the feature on through `[tool.maturin].features` in pyproject.toml. pyo3 = { version = "0.29", features = ["abi3-py39"] } +# Only for the `gate-probe` feature below. +tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } + +[features] +# Gate fact 2 (#1384): a function that blocks with the GIL released, so a +# Python test can send SIGINT during the call. Off in every normal build; +# see docs/gate-facts/keyboard-interrupt.md for how to run it. +gate-probe = ["dep:tokio"] diff --git a/languages/python/crates/engine/src/lib.rs b/languages/python/crates/engine/src/lib.rs index 955db63bd..4cf64543c 100644 --- a/languages/python/crates/engine/src/lib.rs +++ b/languages/python/crates/engine/src/lib.rs @@ -19,9 +19,31 @@ fn build_info() -> (&'static str, &'static str) { ) } +/// Gate fact 2 (#1384): block for `millis` on a multi-thread tokio runtime +/// with the GIL released, the way an engine call will. Returns the +/// milliseconds the call blocked for, measured in Rust. +#[cfg(feature = "gate-probe")] +#[pyfunction] +fn _probe_detached_sleep(py: Python<'_>, millis: u64) -> u64 { + py.detach(|| { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_time() + .build() + .expect("a tokio runtime builds"); + let started = std::time::Instant::now(); + // The sleep is made inside the runtime: tokio's timer needs one. + runtime.block_on(async { + tokio::time::sleep(std::time::Duration::from_millis(millis)).await; + }); + started.elapsed().as_millis() as u64 + }) +} + #[pymodule] fn _engine(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(build_info, module)?)?; + #[cfg(feature = "gate-probe")] + module.add_function(wrap_pyfunction!(_probe_detached_sleep, module)?)?; Ok(()) } diff --git a/languages/python/docs/gate-facts/frame-reader.md b/languages/python/docs/gate-facts/frame-reader.md new file mode 100644 index 000000000..2c0fffd1c --- /dev/null +++ b/languages/python/docs/gate-facts/frame-reader.md @@ -0,0 +1,65 @@ +# Gate fact 1: the extension can read a decrypted record frame + +Checked on 28 October 2026 for #1384. The spec in #1341 lists this fact as a gate on the first ticket. + +## Result + +The fact is true, with one correction to its wording. + +The extension can walk the record frame that `stack_encrypt::dynamic::decrypt` returns, and recover each field's value kind. The extension needs only the public API of `stack-encrypt`. No new engine code is needed. + +## The correction to the wording + +The issue says the extension can "recover the value kind without decrypting". That is not possible for a sealed value. The kind of a sealed value is stored inside its encryption. The doc comment on `dynamic::decrypt` in `packages/stack-encrypt/src/dynamic/record.rs` says: "the type tag is inside the AEAD envelope, so only opening can see it." + +The issue also says that the decrypt checks `decrypt_foreign_row` and `decrypt_malformed` depend on this fact. Neither check needs the value kind: + +- `decrypt_foreign_row` compares the stored `i` value with the expected table and column. The `i` value is plain JSON. +- `decrypt_malformed` checks that the column value parses as EQL JSON. + +What depends on this fact is the step after decrypting. That step builds model instances from the opened record and runs the `decrypt_kind_mismatch` check. The spec in #1341 already puts the kind check after decrypting, so the spec needs no change. + +## Versions checked + +| Crate | Version | Source | +|---|---|---| +| `vitaminc-aead` | 0.8.1 | crates.io | +| `vitaminc-aead-frame` | 0.8.1 | crates.io | +| `stack-encrypt` | commit `98be0721f` | path dependency in this repository | + +## How the reader works + +`stack_encrypt::dynamic` re-exports the two parts the extension needs: + +- `dynamic::frame::Frame::parse` checks that a byte buffer is exactly one frame, before any walk. +- `dynamic::plaintext::Sink` is the trait a walk calls once for each part of a value. The trait is public, and code outside the engine can implement it. + +`Sink::scalar(ty, value)` receives each field's logical type, such as `Date`, and its physical value, such as an `i32`. Those two together are the value kind. + +```rust +let record = ValueFrame::of(User)?; // the type dynamic::decrypt returns +let frame = Frame::parse(record.risky_ref())?; // refuses a malformed frame +frame.visit(Reader { .. })?; // a Sink written outside the engine +``` + +## The probe + +The probe is `crates/core/tests/gate_frame_reader.rs`. Run it from `languages/python`: + +``` +cargo test --locked -p stackencrypt-core --test gate_frame_reader +``` + +The first test builds a record with a text field, an integer field, a date field, and a passthrough field. A `Sink` in the test file walks the record and records each field's kind, but no values. The second test checks that `Frame::parse` refuses a truncated frame. + +Output on 28 October 2026: + +``` +test malformed_frame_bytes_are_refused_before_any_walk ... ok +test an_outside_sink_recovers_each_field_kind_from_a_record_frame ... ok +test result: ok. 2 passed; 0 failed +``` + +## What the probe does not show + +The probe does not encrypt or decrypt. It builds the record with `ValueFrame::of`. The decrypt path returns the same type, `ValueFrame`, as `Pending`. The return type is in `packages/stack-encrypt/src/dynamic/record.rs`, at the `decrypt` function. A round trip through a real cipher is left to the decrypt ticket, #1390. diff --git a/languages/python/docs/gate-facts/keyboard-interrupt.md b/languages/python/docs/gate-facts/keyboard-interrupt.md new file mode 100644 index 000000000..12fda6c51 --- /dev/null +++ b/languages/python/docs/gate-facts/keyboard-interrupt.md @@ -0,0 +1,73 @@ +# Gate fact 2: an interrupt waits for the engine call, and dropped requests wipe their plaintext + +Checked on 28 October 2026 for #1384. The spec in #1341 lists this fact as a gate on the first ticket. The sync contract for engine calls depends on it. + +## Result + +Both parts of the fact are true. + +1. A `KeyboardInterrupt` sent during a call that released the GIL is raised only after the call returns. This was checked by running a probe. +2. A `Pending` that is dropped before it settles wipes the plaintext it contains. This was checked by reading the source, not by running anything. + +The GIL is CPython's global interpreter lock. While a call has released it, other Python threads can run, and the Rust code runs without checking for Python signals. + +## Part 1: the interrupt waits for the call + +### The probe + +The probe has two halves: + +- `_probe_detached_sleep(millis)` in `crates/engine/src/lib.rs`. The function releases the GIL with `py.detach`, then blocks on a multi-thread tokio runtime for `millis` milliseconds. An engine call will block in the same way. The function exists only when the cargo feature `gate-probe` is built, and that feature is off in every normal build. +- `tests/test_gate_keyboard_interrupt.py`. The tests send `SIGINT` to the process 0.2 seconds into a 1-second call. They skip themselves when the extension was built without the feature. + +Run it from `languages/python`: + +``` +MATURIN_PEP517_ARGS="--profile dev --features gate-probe" mise run build +.venv/bin/python -m pytest tests/test_gate_keyboard_interrupt.py -v +mise run build # rebuild without the probe afterwards +``` + +### What it showed + +Output on 28 October 2026, on macOS 14.8.9 (arm64) with Python 3.14.7 and pyo3 0.29.3: + +``` +test_interrupt_waits_for_the_detached_call_to_return PASSED +test_the_extension_is_usable_after_an_interrupt PASSED +1.02s call test_the_extension_is_usable_after_an_interrupt +1.01s call test_interrupt_waits_for_the_detached_call_to_return +``` + +- The `KeyboardInterrupt` arrived after 1.01 seconds. The signal was sent at 0.2 seconds, so the call was not cut short. +- After an interrupt, a second call to the extension succeeded. + +As a control, the same signal interrupted a plain Python `time.sleep(1.0)` after 0.21 seconds. That shows the test sends the signal in time to interrupt a wait that does not release the GIL in Rust. + +### What part 1 does not show + +- The probe blocks on a tokio sleep, not on a real engine call. A real call adds network I/O to ZeroKMS. We expect network I/O to make no difference to how Python delivers a signal. A real call was not run. +- Only macOS was tested. Linux was not run. Windows was not tested, because the test sends the signal with `os.kill`. +- Only Python 3.14.7 was tested. The package supports Python 3.9 and later. + +## Part 2: a dropped `Pending` wipes its plaintext + +A `Pending` in `stack-encrypt` is a set of ZeroKMS requests plus a closure that builds the result from the responses. It is defined in `packages/stack-encrypt/src/target/pending.rs`. Dropping it before it settles drops the closure and everything the closure contains. + +### What a `Pending` contains before it settles + +- On encrypt, each field's plaintext. The engine stores it as a `ValueFrame` or as vitaminc's sealing `Pending`. Both keep their bytes in `Protected>`. +- On decrypt, only ciphertext and the plan. No plaintext exists until the key responses arrive. + +`Protected>` wipes its bytes when it is dropped. `vitaminc-protected` 0.8.1 derives `ZeroizeOnDrop` for it, in `src/protected/mod.rs`, and has a test for that exact type. + +The engine states the same rule for itself. The module docs in `packages/stack-encrypt/src/dynamic/mod.rs` say the engine stores a plaintext as a `ValueFrame`, "in a buffer wiped when it drops". + +### Two exceptions, neither of them secret plaintext + +- A passthrough value is a `PassthroughFrame`. That type is a plain `Vec`, so it is not wiped. Passthrough values are stored unencrypted, so this is by design. +- While a value is walked, a scalar can sit briefly on the stack as a plain integer. That copy is not wiped. `packages/stack-encrypt/src/value.rs`, lines 34 to 36, says so. + +### What part 2 does not show + +Nothing was run for part 2. A runnable check would drop a real `Pending` and read its memory afterwards, as `dropping_wipes_the_value_in_place` in `packages/stack-encrypt/src/value.rs` does for single values. That check needs a cipher with a fake key source. The EQL test crate in `packages/eql/tests/encryption` has one. diff --git a/languages/python/tests/test_gate_keyboard_interrupt.py b/languages/python/tests/test_gate_keyboard_interrupt.py new file mode 100644 index 000000000..ae857fb21 --- /dev/null +++ b/languages/python/tests/test_gate_keyboard_interrupt.py @@ -0,0 +1,63 @@ +"""Gate fact 2 (#1384): a KeyboardInterrupt sent during a call that released +the GIL is raised only after that call returns. + +Runs only against a wheel built with the `gate-probe` cargo feature; see +docs/gate-facts/keyboard-interrupt.md. Skipped otherwise. +""" + +import os +import signal +import sys +import threading +import time + +import pytest + +from stackencrypt import _engine + +probe = getattr(_engine, "_probe_detached_sleep", None) + +pytestmark = [ + pytest.mark.skipif(probe is None, reason="built without the gate-probe feature"), + pytest.mark.skipif(sys.platform == "win32", reason="SIGINT is sent with os.kill"), +] + +CALL_MS = 1000 +SIGNAL_AT_S = 0.2 + + +def send_sigint_later(): + timer = threading.Timer(SIGNAL_AT_S, os.kill, (os.getpid(), signal.SIGINT)) + timer.start() + return timer + + +def call_then_wait_for_the_interrupt(): + probe(CALL_MS) + # Python raises the interrupt at its next check after the call returns. + # The loop gives it that check, and ends the test if it never comes. + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pass + + +def test_interrupt_waits_for_the_detached_call_to_return(): + timer = send_sigint_later() + started = time.monotonic() + with pytest.raises(KeyboardInterrupt): + call_then_wait_for_the_interrupt() + raised_after_s = time.monotonic() - started + timer.join() + + # The signal was sent at 0.2 s. If the call had been cut short, the + # interrupt would arrive then. It must arrive after the call's full second. + assert raised_after_s >= CALL_MS / 1000 + + +def test_the_extension_is_usable_after_an_interrupt(): + timer = send_sigint_later() + with pytest.raises(KeyboardInterrupt): + call_then_wait_for_the_interrupt() + timer.join() + + assert probe(10) >= 10