Repository navigation
stack-encrypt: the Go guest still copies record plaintext into an FfiValue tree — walk frames into vitaminc sinks instead #1232
Description
Background
stack-encrypt is the Rust encryption engine in this repository. The Go SDK runs it as a WebAssembly guest (a .wasm module the Go program loads), and passes values in and out as frames: a compact byte encoding defined by vitaminc, the cryptography library underneath. vitaminc is the CipherStash library that seals each value, one leaf at a time.
vitaminc 0.6 changed how values reach it (vitaminc ADR 0001). There is no shared value type any more. A value describes itself once (the Encrypt trait, the source side), and whatever consumes it is a sink (the Sink trait): an encryptor, an index-term deriver, a frame writer. Section 6 of that ADR says a host binding should walk its frame straight into those sinks, and the Consequences section says "Hosts need no intermediate value type".
Problem
stack-encrypt still has one: stack_encrypt::dynamic::FfiValue, an owned tree of host values (null, numbers, strings, bytes, arrays, objects). Since the move to vitaminc 0.6 it implements vitaminc's two traits, so the sealed bytes are right. But the Go guest still builds the whole tree before the engine reads it:
| Guest path | What happens today |
|---|---|
| Encrypt or derive a term for one value | Walks the frame in place. This already follows the ADR. |
Encrypt a record (encrypt_record) |
Frame → FfiValue tree → the plan picks out each field → each field is sealed from the tree. |
Decrypt a record (decrypt_record) |
Opens into an FfiValue tree, then writes the tree out as a frame. That is an extra plaintext copy in guest memory. |
| Query, verify, check | The input is decoded into an FfiValue first. |
| Control data (plans, contexts, options, selectors, stats, limits) | Decoded from frames into FfiValue. None of it is secret. |
eql-bindings' bridge to EQL target types (encryption/targets.rs) also takes and returns FfiValue.
Why it matters:
- Plaintext residency.
TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt(languages/golang/encrypt/roundtrip_test.go) is skipped. Run at the current vitaminc pin (2413bd0e), it still finds one unwiped copy of each decrypted string in guest memory afterDecrypt. The skip reason, and aead / aead-value: after a decrypt, one unwiped copy of each decrypted string stays in freed heap memory vitaminc#370, put that copy on vitaminc's decrypt path. At this pin that path wipes what it uses:accepts_plaintext_okdecrypts in place inside aZeroizingbuffer, andwalkdrops each opened leaf, which wipes it. So the copy is more likely made on our side, while the record decrypt builds, shapes and encodes theFfiValuetree. That is plausible, not proven: finding the exact copy needs heap instrumentation. - Two value models. The engine keeps a value type that vitaminc deliberately removed. ADR-0002 in this repository still says
FfiValueis renamedValuein vitaminc 0.6. That is no longer true.
Nothing catches either today: the residency test is skipped, and the tree produces correct ciphertext, so every functional test passes.
Proposal
-
Encrypt a record by walking the frame. Add a record sink in
stack_encrypt::dynamic. During one walk of the source frame, it hands each map entry the plan names to that field's sinks: vitaminc'sEncryptSink::root(aad)for the ciphertext, joined with the term sinks byTee. It also needs:- a sink that reads a value as the field's declared kind, as
readdoes today (so a host's34sent as a float is stored as theu64field it is); - a sink that discards entries the plan does not name;
- a first pass over the frame for a plan that takes its context from a field of the record. A frame can be walked more than once.
Keep the single batched key request per call.
- a sink that reads a value as the field's declared kind, as
-
Decrypt a record into a frame. Open every field in one batch, as now, then
walkeach opened field straight into aFrameWritermap entry. NoFfiValueholds the plaintext. -
Read query, verify and check inputs from the frame, as single-value encrypt already does.
-
Decide what control data uses. Plans, contexts, options and the rest carry no secret. They can keep a small, clearly non-secret type, or be parsed from frames directly. Either way,
FfiValuestops carrying plaintext. -
Un-skip
TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt. If it still fails after step 2, find the copy and fix it where it is made. Then re-check aead / aead-value: after a decrypt, one unwiped copy of each decrypted string stays in freed heap memory vitaminc#370's attribution, and correct or close it. -
Amend ADR-0002's
FfiValueparagraph to say that vitaminc 0.6 removed the shared value type rather than renaming it, and that this port is what follows from that.
vitaminc needs no change for this. Every piece used above is public at the pinned commit: Sink/MapSink/SeqSink (not sealed), EncryptSink, Tee, walk, FrameWriter, and vitaminc-normalize's Terms sink.
Relationship to other work
- stack-encrypt: delete
dynamic::ValueandScalar, derive terms through vitaminc, add block ORE, dropBoolorder terms #1140 removeddynamic::ValueandScalar, the mirrors ofFfiValue. This issue is the next step:FfiValueitself leaves the plaintext paths. - aead / aead-value: after a decrypt, one unwiped copy of each decrypted string stays in freed heap memory vitaminc#370: the residency report. Step 5 re-checks it.
- Best done after the open EQL v4 / vitaminc 0.6 stack (feat(stack-encrypt)!: move the stack crates, the guests and the Go module onto vitaminc ADR 0001 #1213, feat(stack-encrypt)!: derive terms through vitaminc-normalize and walk dynamic values as Plaintext #1214, feat(stack-encrypt): query a plan field by operator through the guest, with verify and target_limit as exports #1215) merges into
next, so as not to grow those PRs in review.
Activity