You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
stack-encrypt: a plaintext that is deliberately not Clone cannot be encrypted through the target layer, and every operation copies the plaintext #1077
stack-encrypt (packages/stack-encrypt) is our Rust library for field-level encryption. Every value is sealed under its own data key from ZeroKMS, our key management service, and can carry search terms beside the ciphertext (equality, match, ORE for order-revealing, OPE for order-preserving), so an encrypted column can still be queried. Its target layer (src/target/) is the set of building blocks that describe how a value is encrypted: ciphertext(), equality(), matching(), ore(), ope(), joined with zip. Everything above it (the derive, the plan builder in #1057, and the language bindings) is meant to run through those blocks.
Some plaintext types are deliberately notClone. A secret wrapped so that there is exactly one copy in memory, which is moved into the encryption step and wiped from memory when dropped, must not be copyable, or the copies outlive the wipe. The value type of the WebAssembly (WASI) guest that runs stack-encrypt for the Go binding, FfiValue, is one: it derives Zeroize but not Clone, and its secret leaves live in Protected buffers that wipe on drop (vitaminc-aead-value 0.5.0, src/value.rs:50-51).
Problem
Mechanism. Every building block in the target layer requires its plaintext to be Clone, because each operation copies the value before it encrypts it:
ciphertext is bounded S: Encrypt + Clone (src/target/operations.rs:309), and encrypt_native calls source.clone() before sealing (src/target/core.rs:19, :30).
equality, ore and ope are bounded PrfValue + Clone / CllwOreEncrypt + Clone / CllwOpeEncrypt + Clone (src/target/operations.rs:346, :358, :364), and each term computation clones the source (src/sem/mod.rs:358, :960, :985).
Every operation makes a copy of the plaintext, even for a type that is Clone. A field sealed as a ciphertext with an equality term and an ORE term copies the plaintext three times. For a secret type, each copy is one more place the plaintext sits in memory.
Why nothing catches it today. The bound is a compile-time requirement, so callers that hit it simply do not use the target layer. No test runs a non-Clone plaintext through it, and nothing counts copies.
Proposal
Let a single operation (a ciphertext alone, or one term alone) take a non-Clone plaintext by value, with no copy.
Keep Clone only where it cannot be avoided: running several operations over one owned value (zip). There, the last operation takes ownership and the others get a copy, so n operations make n - 1 copies instead of n. A match term only reads its text and never copies.
Add a public way to run a description held in a variable with an owned value, since today the only entry point (encrypt_as through a type's EncryptFrom impl) always borrows.
Keep every existing spelling working: existing EncryptFrom declarations, records and the derive must compile and produce the same bytes unchanged.
Pin it with tests: a real FfiValue sealed by value and opened back; a clone-counting plaintext that pins the copy counts; a compile-fail check that an owned zip over a non-Clone type is refused with an error that names zip and S: Clone.
Verified against the code: the bounds and clone sites above, on main; FfiValue is not Clone (vitaminc-aead-value 0.5.0). From the fix's PR, verified by its tests: the copy counts and that a non-Clone value can run each single operation.
Background
stack-encrypt (
packages/stack-encrypt) is our Rust library for field-level encryption. Every value is sealed under its own data key from ZeroKMS, our key management service, and can carry search terms beside the ciphertext (equality, match, ORE for order-revealing, OPE for order-preserving), so an encrypted column can still be queried. Its target layer (src/target/) is the set of building blocks that describe how a value is encrypted:ciphertext(),equality(),matching(),ore(),ope(), joined withzip. Everything above it (the derive, the plan builder in #1057, and the language bindings) is meant to run through those blocks.Some plaintext types are deliberately not
Clone. A secret wrapped so that there is exactly one copy in memory, which is moved into the encryption step and wiped from memory when dropped, must not be copyable, or the copies outlive the wipe. The value type of the WebAssembly (WASI) guest that runs stack-encrypt for the Go binding,FfiValue, is one: it derivesZeroizebut notClone, and its secret leaves live inProtectedbuffers that wipe on drop (vitaminc-aead-value0.5.0,src/value.rs:50-51).Problem
Mechanism. Every building block in the target layer requires its plaintext to be
Clone, because each operation copies the value before it encrypts it:ciphertextis boundedS: Encrypt + Clone(src/target/operations.rs:309), andencrypt_nativecallssource.clone()before sealing (src/target/core.rs:19,:30).equality,oreandopeare boundedPrfValue + Clone/CllwOreEncrypt + Clone/CllwOpeEncrypt + Clone(src/target/operations.rs:346,:358,:364), and each term computation clones the source (src/sem/mod.rs:358,:960,:985).(All line numbers are against
main.)Impact.
Cloneplaintext cannot be encrypted through the target layer at all. It does not compile. So the Go binding's guest cannot use the building blocks the rest of the library uses, and must keep its own separate code path. That separate path is exactly what stack-encrypt: add the engine pieces the plan builder needs (passthrough, run-by-value, Index trait, per-field types) — 0.3.0 #1056 and stack-encrypt:dynamic::recordis a second executor that already seals differently from the derive — make it a lowering into the plan builder #1059 set out to remove: both list this bound as a hard prerequisite.Clone. A field sealed as a ciphertext with an equality term and an ORE term copies the plaintext three times. For a secret type, each copy is one more place the plaintext sits in memory.Why nothing catches it today. The bound is a compile-time requirement, so callers that hit it simply do not use the target layer. No test runs a non-
Cloneplaintext through it, and nothing counts copies.Proposal
Cloneplaintext by value, with no copy.Cloneonly where it cannot be avoided: running several operations over one owned value (zip). There, the last operation takes ownership and the others get a copy, sonoperations maken - 1copies instead ofn. A match term only reads its text and never copies.encrypt_asthrough a type'sEncryptFromimpl) always borrows.EncryptFromdeclarations, records and the derive must compile and produce the same bytes unchanged.FfiValuesealed by value and opened back; a clone-counting plaintext that pins the copy counts; a compile-fail check that an ownedzipover a non-Clonetype is refused with an error that nameszipandS: Clone.Verified against the code: the bounds and clone sites above, on
main;FfiValueis notClone(vitaminc-aead-value 0.5.0). From the fix's PR, verified by its tests: the copy counts and that a non-Clonevalue can run each single operation.Relationship to other work
dynamic::recordis a second executor that already seals differently from the derive — make it a lowering into the plan builder #1059 (make the Go guest's record path a lowering into the plan builder): both name this bound as a prerequisite.