Skip to content

v0.24.0 — the attach stops travelling decrypted in stream state

Choose a tag to compare

@rustyconover rustyconover released this 04 Aug 03:20
· 120 commits to main since this release

Catalog credentials no longer reach the access log

VgiCallState carried plaintext_attach — the unwrapped attach (uuid(16) || catalog_bytes) — so each turn could shard storage on its UUID without re-opening the auth-scoped seal.

Two problems. It was redundant: the sealed envelope is already in the same object at init_call.bind_call.attach_opaque_data, so the call state held both the ciphertext and its plaintext. And serialized state does not stay sealed at rest — the access log records the decrypted state at DEBUG as a deliberate audit artifact, and on the init turn that state is call_state || cursor. A decrypted attach therefore base64s catalog credentials into the log, which is exactly what loggable_attach_options exists to prevent elsewhere. It also sat decrypted in the transport's call-state cache.

The comment defending it claimed the seal "can't be reopened on a later, possibly different-auth, produce/finalize turn". That isn't true, and is now a test: continuation turns are ordinary dispatches carrying the caller's identity, so a real multi-turn HTTP stream shows the identity present and the envelope re-opening on every turn. Re-opening also restores a per-turn identity check the cached copy had quietly skipped.

Making it free

Re-opening cost 42 µs/turn — ~13% of a turn, which is why the copy existed. The open is memoized on the worker, keyed by (envelope, caller identity):

before after
warm unwrap 35–42 µs 1.19 µs
catalog stream 42 µs/turn 1.54 µs/turn
non-catalog stream 0.13 µs 0.14 µs (no envelope)

The identity half of that key is load-bearing. An envelope's AAD is the only thing binding it to a principal, and a cache hit skips the AEAD — so keyed on the envelope alone (hashed or not), a stolen envelope becomes a bearer token for anyone. Keyed on both, a mismatched caller misses and takes the open, which fails as it should. Only successes are cached, so a rejection never becomes a later hit. Tests cover cross-principal and anonymous callers against a warm cache, non-poisoning, and the eviction bound.

The cache holds plaintext in memory, which is unavoidable — the worker cannot resolve a storage shard without it — and widens nothing that opening the envelope already did. The property protected is that it never reaches serialized state or logs.

Fixes

  • MetaWorker could not open an attach. Re-opening on the rehydrate path calls the object the transport supplies as the implementation, and under a MetaWorker that is not a Worker — it proxies rather than subclasses. Every HTTP continuation of a catalog-backed stream failed. It now routes the way _resolve_function does, to the sub-worker owning the catalog.
  • _partition_fields_cached conflated schemas. It was keyed on pa.Schema, whose __eq__/__hash__ ignore field metadata — the exact thing the function reads. An annotated schema and an unannotated one of the same names and types shared an entry, so a function with no partition annotations inherited a neighbour's fields and the partition_values contract check passed where it had to raise. Now keyed through a wrapper that keeps the cheap metadata-blind hash and discriminates via equals(check_metadata=True).

Both were caught by the integration suite while 2,132 unit tests passed.

Also

  • Requires vgi-rpc >= 0.33.0.
  • The haybarn engine extra moves to 1.5.5rc1, matching the build the vgi extension is compiled against — in-process filter evaluation and the extension now agree.

Integration green on every transport: subprocess 10,334 assertions, HTTP 10,020, and all four CI lanes (stdio, http, shm, launch).