Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Durable Journal Encryption

The durable execution layer journals the control flow of an execution — step results, promise resolutions, and checkpoint snapshots — to a dedicated durable.db database so an interrupted execution can resume rather than restart. Those payloads can contain sensitive intermediate data, so they are sealed with an authenticated cipher before they touch disk.

Cipher

Payloads are encrypted with XChaCha20-Poly1305 (AEAD), a 192-bit extended-nonce construction. A fresh random nonce is drawn from the operating system CSPRNG on every seal, so no nonce-sequencing state has to be persisted and nonce reuse under a fixed key cannot occur.

The stored blob layout is:

key_id(1 byte) || nonce(24 bytes) || ciphertext || Poly1305 tag(16 bytes)

The leading key_id byte selects which key decrypts the blob, enabling the rotation window described below.

Associated data (tamper-evidence)

Every seal binds the payload to its journal location through the AEAD associated data: (execution_id, step_id, entry_kind, idempotency_key). As a result a sealed result cannot be silently relocated — moving a blob to a different step, or replaying it under a different execution, changes the associated data and makes decryption fail authentication. A forged or moved entry is rejected (fail-closed) rather than decrypted into a bogus result.

Vault key: ZEPH_DURABLE_KEY

The cipher key is resolved from the age vault under the key name ZEPH_DURABLE_KEY, never from inline TOML or environment variables (the standard Zeph vault contract). It is exactly 32 bytes of high-entropy key material, base64-encoded for storage as a vault string value.

The easiest path is the configuration wizard: zeph --init generates a fresh key and stores it in the age vault automatically when you enable durable execution. To generate and store it manually instead:

# Generate 32 random bytes, base64-encode them, and store in the age vault.
head -c 32 /dev/urandom | base64 | zeph vault set ZEPH_DURABLE_KEY --stdin

Inspect a journal with decrypted payloads using zeph durable show <id> --reveal, which resolves and decodes this key.

Encryption requirement (encrypt_payload)

AEAD encryption is on by default ([durable].encrypt_payload = true). Disabling it is a development-only override and is governed by the deployment:

Deploymentencrypt_payload = false
Single-user local SQLiteAllowed; logs a startup WARN
Shared database (Postgres / shared)Forbidden — startup error
Restate backendForbidden — startup error

The rationale is the trust boundary: a single-user SQLite file inherits the operating-system file permissions, but a shared or networked database does not, so the journal must protect its own payloads there.

“Shared database” is determined by [durable].shared_db: set it true whenever the journal database is reachable by more than one process or client (a network-shared volume, or any future Postgres-backed deployment). It defaults to false for an ordinary single-user local setup. A postgres:///postgresql:// journal URL is also treated as shared automatically, even if shared_db was left unset, as defense in depth.

Control-entry HMAC (EffectIntent forgery protection)

Some journal entries carry no payload at all — an EffectIntent records the intent to run an exactly-once-guarded effect before it fires, so there is nothing to encrypt. On a shared database these “control” rows still need tamper-evidence: an attacker who can insert rows directly should not be able to forge or relocate an EffectIntent and trick a resumed execution into skipping or re-running a guarded effect.

For a declared/detected shared database, every EffectIntent row is stamped with a row-level HMAC over its identity — (execution_id, step_id, entry_kind, idempotency_key) — keyed with a BLAKE3 subkey derived from ZEPH_DURABLE_KEY (domain-separated from the AEAD payload key, so the two keys are cryptographically independent even though they share one vault secret). Every read of an EffectIntent recomputes and constant-time-verifies this HMAC; a mismatch — including a row missing its HMAC on a keyed backend — is rejected fail-closed.

This uses the same shared_db/postgres://-detection gate described above: a single-user local, non-shared database never computes or verifies this HMAC (the row’s hmac column stays NULL), matching the accepted stance that the DB-file trust boundary already covers that deployment.

This forgery guarantee depends on shared_db/postgres:// being declared consistently across the writer and every reader of a given journal file; a reader that disagrees (e.g. runs unkeyed against a keyed writer’s file) now fails closed as soon as it encounters any row carrying a stamped HMAC, rather than silently trusting it as an ordinary unverified field.

Like the AEAD payload cipher, the control-entry HMAC key has its own rotation window: verification tries the current key first, falling back to a registered previous key while a zeph durable rotate-key window is open, so a pre-rotation EffectIntent control entry stays readable on every read path (agent replay, scheduler daemon, and the CLI) through the window. Unlike the AEAD cipher, the stored hmac column carries no key-id selector — control entries have no payload envelope to carry one — so verification tries both keys rather than dispatching by an on-disk selector; this is security-equivalent for the single-slot window rotate-key supports.

High-water-mark (deletion detection)

The AEAD seal and the control-entry HMAC both protect a row’s own content and identity, but neither detects a committed StepResult row being deleted outright. A per-execution high-water-mark closes that gap: a signed {key_epoch, max_committed_step_id, committed_result_count} tuple is updated in the same transaction as every committed StepResult, and verified once on every resume. Unlike the control-entry HMAC, the high-water-mark is attached unconditionally — including single-user local deployments, which get deletion detection they would not otherwise have.

The high-water-mark key shares ZEPH_DURABLE_KEY’s rotation lifecycle: its epoch is [durable] key_id (current) / previous_key_id (previous), the same fields rotate-key drives for the AEAD cipher, so no separate rotation procedure or flag is needed. A resumed execution whose signed epoch matches neither the current nor a registered previous key fails closed as “possibly re-keyed” rather than a generic tamper report, distinguishing a legitimate rotation the process cannot resolve from actual tampering; either way the resume is refused with no interactive override.

Key rotation

The key_id byte makes rotation possible without rewriting the journal. zeph durable rotate-key drives the whole procedure:

# Open a window: generates a fresh ZEPH_DURABLE_KEY, stashes the old key under
# ZEPH_DURABLE_KEY_PREVIOUS, and bumps [durable] key_id / previous_key_id in the config.
zeph durable rotate-key

# Preview what would change without writing anything.
zeph durable rotate-key --dry-run

New payloads seal under the new key; payloads sealed before the rotation still decrypt through the registered previous key. Only one rotation window is open at a time — running rotate-key again while a window is already open is refused (the cipher has a single previous-key slot; a second rotation would silently orphan the first previous key), so close the current window first.

The cipher is built once at process startup and does not hot-reload, so a restart is required after rotating for every consumer (agent process, scheduler daemon, --reveal, the TUI durable panel) to pick up the new key.

Once every execution that used the old key has reached a terminal status and been pruned — the default retention window is roughly 30 days; see [durable.retention] — close the window:

zeph durable rotate-key --drop-previous

This removes ZEPH_DURABLE_KEY_PREVIOUS from the vault and clears previous_key_id. By default it runs three independent safety scans and refuses the drop if any finds a surviving dependency on the previous key:

  • an AEAD blob-scan, refusing if any payload is still sealed under the old key,
  • a control-entry HMAC scan, refusing if any EffectIntent still verifies only under the previous key (catching a payload-less crash-orphaned intent the blob-scan cannot see), and
  • a high-water-mark scan, refusing if any execution’s signed high-water-mark still carries the previous key epoch (catching a checkpoint-folded pre-rotation execution, whose payload and control entries may both already be gone even though its high-water-mark has not migrated).

Pass --force to skip all three scans once you have independently confirmed pruning is complete. Payloads and control/high-water-mark state still bound to the dropped key become permanently unreadable afterward. A call with no window open is a clean no-op.

On a shared database ([durable].shared_db = true, or a postgres:// journal URL), rotating also changes the derived control-entry HMAC key — this now has its own rotation window (see above), so shared-database rotation needs no special acknowledgement and works exactly like a single-user local rotation.

zeph --init’s wizard step can also replace ZEPH_DURABLE_KEY, but that path is a destructive reset: it discards the old key immediately with no rotation window, orphaning every existing sealed payload right away. Prefer zeph durable rotate-key unless you specifically want to discard every existing payload and start over.