Skip to content

stack-encrypt: the Go guest still copies record plaintext into an FfiValue tree — walk frames into vitaminc sinks instead #1232

Open

Description

@coderdan

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:

  1. 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 after Decrypt. 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_ok decrypts in place inside a Zeroizing buffer, and walk drops each opened leaf, which wipes it. So the copy is more likely made on our side, while the record decrypt builds, shapes and encodes the FfiValue tree. That is plausible, not proven: finding the exact copy needs heap instrumentation.
  2. Two value models. The engine keeps a value type that vitaminc deliberately removed. ADR-0002 in this repository still says FfiValue is renamed Value in 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

  1. 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's EncryptSink::root(aad) for the ciphertext, joined with the term sinks by Tee. It also needs:

    • a sink that reads a value as the field's declared kind, as read does today (so a host's 34 sent as a float is stored as the u64 field 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.

  2. Decrypt a record into a frame. Open every field in one batch, as now, then walk each opened field straight into a FrameWriter map entry. No FfiValue holds the plaintext.

  3. Read query, verify and check inputs from the frame, as single-value encrypt already does.

  4. 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, FfiValue stops carrying plaintext.

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

  6. Amend ADR-0002's FfiValue paragraph 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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    SDKenhancementNew feature or requestrustPull requests that update Rust code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions