Skip to content

stack-encrypt: a plaintext that is deliberately not Clone cannot be encrypted through the target layer, and every operation copies the plaintext #1077

Description

@coderdan

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

(All line numbers are against main.)

Impact.

  1. A non-Clone plaintext 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::record is 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.
  2. 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

  1. Let a single operation (a ciphertext alone, or one term alone) take a non-Clone plaintext by value, with no copy.
  2. 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.
  3. 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.
  4. Keep every existing spelling working: existing EncryptFrom declarations, records and the derive must compile and produce the same bytes unchanged.
  5. 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.

Relationship to other work

Activity

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

Metadata

Metadata

Assignees

Labels

SDKenhancementNew feature or requestrustPull requests that update Rust code

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions