End-to-end payload encryption

TLS protects payloads from outsiders. End-to-end encryption protects them from the party operating the control plane, including a hosted Hopskip’s own team and anyone who compromises the control plane’s processes, storage, or backups.

With it enabled, workflow inputs, activity inputs and results, signal/query/update payloads, channel messages, checkpoint values, and their offloaded blobs are sealed before they leave the client or worker process and opened only there. The server stores, routes, retries, and meters ciphertext; no code path in it constructs a decryption key. Workflow code is below the boundary and observes plaintext exactly as it would without encryption, so replay, determinism checking, and every guest language work unchanged, in every SDK.

The envelope

A sealed payload is a serialized hopskip.payload.v1.Payload (the canonical headers + data bundled payload shape) whose headers carry the encryption metadata and whose data is AES-256-GCM ciphertext of another serialized Payload holding the original headers and bytes:

Payload {
  headers: {
    "content-type":            "application/vnd.hopskip.encrypted",
    "hopskip-e2ee-version":       "1",
    "hopskip-e2ee-scope":         "hopskip-namespace:orders",
    "hopskip-e2ee-kek-id":        which key wrapped this payload's key,
    ...nonces, the wrapped data-encryption key...
  },
  data: <ciphertext of Payload { original headers, original bytes }>,
}

Each payload gets a fresh data-encryption key (DEK), wrapped under the scope’s key-encryption key (KEK). The original headers, including the logical type and schema identity, travel inside the ciphertext: a control-plane reader learns “an encrypted payload of N bytes for namespace S”, never “an acme.Order”. Every encryption header is bound into the AEAD as associated data, so a tampered or re-scoped envelope fails to decrypt rather than decrypting wrongly. Two seals of the same payload never produce the same bytes, so the control plane cannot even learn that two payloads are equal.

Keyed replay commitments

Hopskip’s determinism checking records a digest of every command a workflow emits (which activity, with which payload) in durable history. Unkeyed, those digests would let whoever stores them confirm guesses about low-entropy payloads by brute force: hash {"approve": true} and compare. Under end-to-end encryption they are keyed instead. Each run’s start seal mints a random commitment key that travels wrapped inside the input envelope, and workers hash the run’s commands with HMAC-SHA-256 under it. The control plane stores digests it cannot recompute; replay matching is untouched, because both sides of every comparison run in workers holding the key. The wrapped key is bound into the envelope’s authentication, so it cannot be stripped in transit to downgrade a run to guessable hashing.

Keys, rotation, and managed key infrastructure

Keys are scoped per namespace and supplied by a pluggable provider. Pick by how your organization manages keys:

Keyring file (self-managed keys)

# /etc/hopskip/payload-keyring - one version per line, highest seals
v1:6ea27a2d…3d51
v2:8c1b9adb…f1c0
export HOPSKIP_PAYLOAD_E2EE_KEY_FILE=/etc/hopskip/payload-keyring
# dev shorthand, a one-line keyring:
export HOPSKIP_PAYLOAD_E2EE_KEY=<64 hex chars>

Per-namespace keys derive from the master keys (HMAC-SHA256), so every process holding the same file interoperates with no per-namespace distribution step. Rotation is adding a line: generate a new 32-byte key, append it as v<N+1>, roll the file out. New payloads seal under the newest version; everything sealed under any listed version still opens (each envelope records which key wrapped it). Deleting a line is a deliberate crypto-shred of everything sealed under that version.

HashiCorp Vault (Transit engine)

export HOPSKIP_PAYLOAD_E2EE_PROVIDER=vault
export VAULT_ADDR=https://vault.internal:8200
export VAULT_TOKEN_FILE=/var/run/secrets/vault-token
# optional: HOPSKIP_PAYLOAD_E2EE_VAULT_MOUNT (default "transit")
#           HOPSKIP_PAYLOAD_E2EE_VAULT_KEY_PREFIX (default "hopskip-e2ee-")

One Transit key per namespace, created on first use. Key material never enters the Hopskip process: wrap and unwrap are Vault operations, governed by your Vault policies. Rotation is Vault’s own (vault write -f transit/keys/hopskip-e2ee-<namespace>/rotate) and takes effect on the next payload with no file to distribute; old payloads keep decrypting because Vault tokens carry their key version. Deleting the Transit key is a real, Vault-audited crypto-shred of the namespace.

Binaries need the vault feature of hopskip-payload-crypto compiled in; a worker asked for Vault without it refuses to start rather than silently running unencrypted.

AWS KMS / GCP Cloud KMS

The aws and gcp features provide KMS-backed providers (the same ones @sensitive field encryption uses): key material stays inside the KMS, credentials come from each SDK’s standard chain (IRSA, instance roles, Application Default Credentials), and rotation is the KMS’s own. These are constructed in code (PayloadCipher::new) rather than by environment variable, because cloud credential configuration does not reduce to one variable.

Whichever provider: key loss is data loss. The operator cannot recover what only your keys decrypt. That is the feature, and it is also the warning.

Configuration, end to end

WhereSetting
Workers (hopskip-wasm-worker, hopskip-v8-worker)HOPSKIP_PAYLOAD_E2EE_KEY_FILE (or provider env above); dispatches open before workflow code sees them, results seal before they leave the process
ClientsThe same env for hop run; HopskipClient::with_payload_cipher in the Rust SDK
SchedulesScheduleClient::with_payload_cipher: a schedule’s action input is sealed in the stored definition, so every firing starts its workflow encrypted
hop devHonors the same env, so an encrypted deployment rehearses on a laptop
Control planeHOPSKIP_REQUIRE_ENCRYPTED_PAYLOADS=<ns1,ns2> (or *): refuses plaintext payloads into listed namespaces, on the client RPCs and on worker completions

HOPSKIP_REQUIRE_ENCRYPTED_PAYLOADS is a tripwire, not the guarantee. The server cannot cryptographically verify a seal (it has no keys), but it can loudly refuse a client that meant to encrypt and silently was not configured to, which is the misconfiguration that otherwise surfaces months later as plaintext in the durable log. The guarantee itself lives in where the keys are.

A worker that cannot decrypt a dispatch fails the attempt as retryable (another worker holding the key can serve it); a worker that cannot seal a result fails the attempt rather than completing in plaintext.

The semi-managed topology

End-to-end encryption forces the question “where may plaintext exist?” The deployment answers it. In the semi-managed configuration the hosted control plane owns exactly two things: the append-log cluster and the orchestrator (dispatch, retries, scheduling, metering, visibility over indexed attributes). Everything that touches plaintext runs on your infrastructure:

  • Workers dial out to the control plane over gRPC/TLS with a bearer credential: outbound 443, no inbound ports, no VPN. Run the standard worker images on your compute with your key configuration. Worker authorization (HOPSKIP_WORKER_AUTHZ, HOPSKIP_WORKER_AUTHZ_NAMESPACES) scopes what each worker principal may serve.
  • The console is its own deployable (a static frontend behind an oauth2-proxy, the same deploy/k8s/base/console/ unit the hosted stack uses) and should be self-hosted beside your workers, pointed at the hosted control plane’s API with your identity provider. Under E2EE the API can only serve it ciphertext and markers. Self-hosting makes that a topology fact rather than an API promise, and it means the JavaScript your operators run is served by you, not by the party the encryption defends against. Encrypted payloads render as a marker (scope, key id, size); decrypt locally with your keys when you need the contents.
  • Resume state: point the workers’ shared resume-bundle store at storage you own. A suspended workflow’s memory snapshot contains plaintext state.

What stays visible, by design

Namespaces, workflow type names, instance names, timing, payload sizes, and failure diagnostics remain visible to the control plane, because it cannot schedule, retry, or meter without them. So does anything a workflow explicitly emits as a @searchable/@indexed attribute: emitting one is publishing it to the server’s index. That is the search/privacy lever, per attribute, in your hands. Index what you can afford the operator to see; encrypt the rest and search on what you indexed.

Erasing a namespace’s key (removing its keyring line, deleting its Transit key) is a namespace-wide crypto-shred: the append-only log and its hash chain stay intact and auditable while every payload under that key becomes permanently unreadable.