diff --git a/CHANGELOG.md b/CHANGELOG.md index adf466b59..e438577c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). tables (`durable_executions`, `durable_journal`, `durable_promises`, `durable_timers`) were added as numbered migrations `097`–`100` in both `zeph-db/migrations/sqlite/` and `.../postgres/`; `zeph-durable` owns no `.sql` files and no `sqlx::migrate!` (INV-14). (#4944) +- `feat(durable)`: added the journal payload AEAD boundary. `zeph-durable` now defines the + `PayloadCipher` seal/open trait, the `PayloadAad` location binding + (`execution_id`/`step_id`/`entry_kind`/`idem_key`) with a deterministic injective + `canonical_bytes` encoding, the `EntryKindTag` discriminator (`EntryKind::tag_enum`/`tag` now + delegate to a single source of truth), the metadata-only `CipherError` (with a fail-closed + `From` for `DurableError`), and the `ensure_payload_within_limit` read-side + `max_payload` guard (INV-11, no decode before the size check). `DurableConfig::encryption_gate` + enforces INV-8: AEAD may be disabled only for a single-user local backend (startup `WARN`), and + is rejected for shared-database or Restate deployments (`DurableError::EncryptionRequired`). The + concrete XChaCha20-Poly1305 cipher lives in `zeph-core::durable::XChaCha20Poly1305Cipher` (keeps + `zeph-durable` crypto-dependency-free, INV-1): fresh 192-bit CSPRNG nonce per seal (INV-7), + `key_id || nonce(24) || ciphertext || tag(16)` blob layout with a one-key rotation window, and + zeroized key material. Keyed from the vault `ZEPH_DURABLE_KEY`; see the new "Durable Journal + Encryption" security reference page for the key-rotation policy. (#4945) ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1320f9f4a..e4b2407a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,6 +1662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -10779,6 +10780,7 @@ dependencies = [ "age", "base64 0.22.1", "blake3", + "chacha20poly1305", "chrono", "cpu-time", "criterion", @@ -10824,6 +10826,7 @@ dependencies = [ "zeph-config", "zeph-context", "zeph-db", + "zeph-durable", "zeph-experiments", "zeph-index", "zeph-llm", diff --git a/Cargo.toml b/Cargo.toml index 2e93316a8..8baf9d45f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ bytes = "1.11.1" candle-core = { version = "0.10.2", default-features = false } candle-nn = { version = "0.10.2", default-features = false } candle-transformers = { version = "0.10.2", default-features = false } +chacha20poly1305 = "0.10.1" chrono = { version = "0.4.44", default-features = false } clap = "4.6.1" cpu-time = "1.0" @@ -162,6 +163,7 @@ zeph-config = { path = "crates/zeph-config", version = "0.21.4" } zeph-context = { path = "crates/zeph-context", version = "0.21.4" } zeph-core = { path = "crates/zeph-core", version = "0.21.4" } zeph-db = { path = "crates/zeph-db", default-features = false, version = "0.21.4" } +zeph-durable = { path = "crates/zeph-durable", default-features = false, version = "0.21.4" } zeph-experiments = { path = "crates/zeph-experiments", version = "0.21.4" } zeph-gateway = { path = "crates/zeph-gateway", version = "0.21.4" } zeph-index = { path = "crates/zeph-index", version = "0.21.4" } diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 94f11559b..2f175473d 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -99,6 +99,7 @@ - [Untrusted Content Isolation](reference/security/untrusted-content-isolation.md) - [File Read Sandbox](reference/security/file-sandbox.md) - [ShadowSentinel Safety Probing](reference/security/shadow-sentinel.md) + - [Durable Journal Encryption](reference/security/durable-encryption.md) # Development diff --git a/book/src/reference/security/durable-encryption.md b/book/src/reference/security/durable-encryption.md new file mode 100644 index 000000000..8c0cd3ecf --- /dev/null +++ b/book/src/reference/security/durable-encryption.md @@ -0,0 +1,77 @@ +# 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: + +```text +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 must be exactly **32 bytes** of high-entropy key +material. + +Generate and store it once: + +```bash +# Generate 32 random bytes and store them in the age vault. +head -c 32 /dev/urandom | zeph vault set ZEPH_DURABLE_KEY --stdin +``` + +## 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: + +| Deployment | `encrypt_payload = false` | +| --------------------------------------- | ------------------------- | +| Single-user **local SQLite** | Allowed; logs a startup `WARN` | +| **Shared database** (Postgres / shared) | **Forbidden** — startup error | +| **Restate** backend | **Forbidden** — 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. + +## Key rotation + +The `key_id` byte makes rotation possible without rewriting the journal: + +1. Generate a new key and assign it the next `key_id`. +2. Run with the new key as **current** and the old key registered as the + **previous** key. New entries seal under the new key; in-flight entries sealed + under the old key still decrypt during this window. +3. Once all executions that used the old key have reached a terminal status + (drain), remove the old key. + +If you prefer not to run a rotation window, the simpler drain-based policy is to +**quiesce** the durable layer — let all running executions reach a terminal +status — before swapping `ZEPH_DURABLE_KEY`. After a clean drain there are no +entries sealed under the old key, so no previous-key window is needed. diff --git a/crates/zeph-core/Cargo.toml b/crates/zeph-core/Cargo.toml index ea75802c4..a90b2eac0 100644 --- a/crates/zeph-core/Cargo.toml +++ b/crates/zeph-core/Cargo.toml @@ -22,16 +22,17 @@ cocoon = ["zeph-llm/cocoon", "zeph-commands/cocoon"] index = ["zeph-agent-context/index"] metal = ["zeph-llm/metal"] mock = ["zeph-vault/mock"] -postgres = ["zeph-db/postgres", "zeph-agent-context/postgres", "zeph-agent-persistence/postgres"] +postgres = ["zeph-db/postgres", "zeph-agent-context/postgres", "zeph-agent-persistence/postgres", "zeph-durable/postgres"] profiling = ["dep:tracing-subscriber", "zeph-commands/profiling"] profiling-alloc = ["profiling"] scheduler = [] -sqlite = ["zeph-db/sqlite", "zeph-agent-context/sqlite", "zeph-agent-persistence/sqlite"] +sqlite = ["zeph-db/sqlite", "zeph-agent-context/sqlite", "zeph-agent-persistence/sqlite", "zeph-durable/sqlite"] sysinfo = ["dep:sysinfo"] [dependencies] base64.workspace = true blake3.workspace = true +chacha20poly1305.workspace = true chrono.workspace = true cpu-time.workspace = true dirs.workspace = true @@ -71,6 +72,7 @@ zeph-common.workspace = true zeph-config.workspace = true zeph-context.workspace = true zeph-db.workspace = true +zeph-durable.workspace = true zeph-experiments.workspace = true zeph-index.workspace = true zeph-llm.workspace = true diff --git a/crates/zeph-core/src/durable.rs b/crates/zeph-core/src/durable.rs new file mode 100644 index 000000000..ac88d1f1b --- /dev/null +++ b/crates/zeph-core/src/durable.rs @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Concrete cryptographic backing for the durable execution layer. +//! +//! `zeph-durable` defines the durable execution *contract* as a pure Layer-0 abstraction and +//! deliberately carries no cryptographic dependency (INV-1). This module supplies the concrete +//! [`XChaCha20Poly1305Cipher`] that satisfies [`zeph_durable::PayloadCipher`]. The binary +//! constructs it from the vault-resolved `ZEPH_DURABLE_KEY` and injects it into a backend as +//! `Option>`, exactly as a database pool is handed in. +//! +//! `XChaCha20-Poly1305` is chosen for its 192-bit extended nonce: a fresh random nonce per seal +//! (INV-7) has a negligible collision probability even across the lifetime of a long-lived key, so +//! no nonce-sequencing state has to be persisted. +//! +//! # Examples +//! +//! ``` +//! use zeph_core::durable::XChaCha20Poly1305Cipher; +//! use zeph_durable::{ExecutionId, StepId, PayloadCipher}; +//! use zeph_durable::cipher::{EntryKindTag, PayloadAad}; +//! +//! let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]); +//! let aad = PayloadAad::new(ExecutionId::new(), StepId::new(0), EntryKindTag::StepResult, None); +//! +//! let sealed = cipher.seal(b"tool result", &aad).unwrap(); +//! assert_eq!(cipher.open(&sealed, &aad).unwrap(), b"tool result"); +//! ``` + +use chacha20poly1305::{ + Key, KeyInit, XChaCha20Poly1305, XNonce, + aead::{Aead, AeadCore, OsRng, Payload}, +}; +use zeph_durable::{CipherError, PayloadAad, PayloadCipher}; +use zeroize::Zeroize; + +/// `XChaCha20-Poly1305` key size, in bytes. +const KEY_LEN: usize = 32; +/// `XChaCha20` extended nonce size, in bytes. +const NONCE_LEN: usize = 24; +/// `Poly1305` authentication tag size, in bytes. +const TAG_LEN: usize = 16; +/// Length of the leading key-id selector byte. +const KEY_ID_LEN: usize = 1; +/// Offset one past the nonce, where the ciphertext begins. +const NONCE_END: usize = KEY_ID_LEN + NONCE_LEN; +/// Smallest valid sealed blob: `key_id || nonce || tag` (empty ciphertext). +const MIN_SEALED_LEN: usize = NONCE_END + TAG_LEN; + +/// Failure constructing an [`XChaCha20Poly1305Cipher`] from raw vault bytes. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CipherKeyError { + /// The vault-resolved key was not exactly 32 bytes. + #[error("durable cipher key must be {expected} bytes, got {actual}")] + InvalidKeyLength { + /// The required key length in bytes (32). + expected: usize, + /// The length of the supplied key material. + actual: usize, + }, +} + +/// One key registered with the cipher, addressed by its on-disk key-id byte. +struct KeySlot { + key_id: u8, + cipher: XChaCha20Poly1305, +} + +impl KeySlot { + /// Build a slot, copying the key into the AEAD state and zeroizing the transient input. + fn new(key_id: u8, mut key: [u8; KEY_LEN]) -> Self { + let cipher = XChaCha20Poly1305::new(Key::from_slice(&key)); + key.zeroize(); + Self { key_id, cipher } + } +} + +/// A vault-keyed `XChaCha20-Poly1305` [`PayloadCipher`] with a one-key rotation window. +/// +/// The cipher holds a *current* key used for all seals, plus an optional *previous* key that +/// [`open`](PayloadCipher::open) can still select during a rotation window. The on-disk layout +/// `key_id(1) || nonce(24) || ciphertext || tag(16)` lets `open` pick the right key by its leading +/// byte; an unrecognized key-id fails closed with [`CipherError::UnknownKeyId`]. +/// +/// Key rotation is otherwise drain-based: see `book` vault documentation for the operational +/// policy. See [`zeph_durable::PayloadCipher`] for the full contract. +pub struct XChaCha20Poly1305Cipher { + current: KeySlot, + previous: Option, +} + +impl XChaCha20Poly1305Cipher { + /// Construct a cipher with a single current key identified by `key_id`. + /// + /// The `key` array is zeroized once copied into the AEAD state. + #[must_use] + pub fn new(key_id: u8, key: [u8; KEY_LEN]) -> Self { + Self { + current: KeySlot::new(key_id, key), + previous: None, + } + } + + /// Construct a cipher from vault-resolved key bytes, validating the length. + /// + /// # Errors + /// + /// Returns [`CipherKeyError::InvalidKeyLength`] when `key` is not exactly 32 bytes. + /// + /// # Examples + /// + /// ``` + /// use zeph_core::durable::XChaCha20Poly1305Cipher; + /// + /// assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok()); + /// assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, b"too short").is_err()); + /// ``` + pub fn from_vault_bytes(key_id: u8, key: &[u8]) -> Result { + let array: [u8; KEY_LEN] = + key.try_into() + .map_err(|_| CipherKeyError::InvalidKeyLength { + expected: KEY_LEN, + actual: key.len(), + })?; + Ok(Self::new(key_id, array)) + } + + /// Register a previous key for the rotation window. + /// + /// `open` will select this key for blobs whose leading key-id byte matches `key_id`; `seal` + /// always uses the current key. Use this so in-flight executions sealed under the old key can + /// still be replayed after a rotation. + #[must_use] + pub fn with_previous(mut self, key_id: u8, key: [u8; KEY_LEN]) -> Self { + self.previous = Some(KeySlot::new(key_id, key)); + self + } + + /// Select the AEAD state for a given on-disk key-id. + fn select(&self, key_id: u8) -> Option<&XChaCha20Poly1305> { + if key_id == self.current.key_id { + Some(&self.current.cipher) + } else { + self.previous + .as_ref() + .filter(|slot| slot.key_id == key_id) + .map(|slot| &slot.cipher) + } + } +} + +impl PayloadCipher for XChaCha20Poly1305Cipher { + fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result, CipherError> { + let aad_bytes = aad.canonical_bytes(); + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = self + .current + .cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad: &aad_bytes, + }, + ) + .map_err(|_| CipherError::Authentication)?; + + let mut blob = Vec::with_capacity(KEY_ID_LEN + NONCE_LEN + ciphertext.len()); + blob.push(self.current.key_id); + blob.extend_from_slice(nonce.as_slice()); + blob.extend_from_slice(&ciphertext); + Ok(blob) + } + + fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result, CipherError> { + if sealed.len() < MIN_SEALED_LEN { + return Err(CipherError::Malformed { + context: "sealed blob shorter than key-id + nonce + tag", + }); + } + let key_id = sealed[0]; + let cipher = self + .select(key_id) + .ok_or(CipherError::UnknownKeyId { key_id })?; + + let nonce = XNonce::from_slice(&sealed[KEY_ID_LEN..NONCE_END]); + let ciphertext = &sealed[NONCE_END..]; + let aad_bytes = aad.canonical_bytes(); + + cipher + .decrypt( + nonce, + Payload { + msg: ciphertext, + aad: &aad_bytes, + }, + ) + .map_err(|_| CipherError::Authentication) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use zeph_durable::cipher::EntryKindTag; + use zeph_durable::{DurableError, ExecutionId, StepId}; + + use super::*; + + fn aad_for(exec: ExecutionId, step: u32) -> PayloadAad { + PayloadAad::new(exec, StepId::new(step), EntryKindTag::StepResult, None) + } + + #[test] + fn seal_open_round_trip() { + let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + for plaintext in [ + b"".as_slice(), + b"x", + b"a longer journaled tool result payload", + ] { + let sealed = cipher.seal(plaintext, &aad).unwrap(); + assert_eq!(cipher.open(&sealed, &aad).unwrap(), plaintext); + } + } + + #[test] + fn sealed_blob_uses_key_id_nonce_tag_layout() { + let cipher = XChaCha20Poly1305Cipher::new(3, [2u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let sealed = cipher.seal(b"", &aad).unwrap(); + // key-id byte, then 24-byte nonce, then a 16-byte tag for empty plaintext. + assert_eq!(sealed.len(), KEY_ID_LEN + NONCE_LEN + TAG_LEN); + assert_eq!(sealed[0], 3, "leading byte is the current key-id"); + } + + #[test] + fn nonce_is_fresh_per_seal() { + let cipher = XChaCha20Poly1305Cipher::new(0, [9u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let a = cipher.seal(b"same", &aad).unwrap(); + let b = cipher.seal(b"same", &aad).unwrap(); + // Identical plaintext + identical AAD must still yield distinct nonces (and ciphertext). + assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]); + assert_ne!(a, b); + } + + // NFR-DE-06: a CSPRNG nonce of 192 bits must not repeat across 10^6 seals. + #[test] + fn one_million_seals_produce_distinct_nonces() { + const SEALS: usize = 1_000_000; + let cipher = XChaCha20Poly1305Cipher::new(0, [4u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let mut nonces: HashSet<[u8; NONCE_LEN]> = HashSet::with_capacity(SEALS); + for _ in 0..SEALS { + let sealed = cipher.seal(b"", &aad).unwrap(); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&sealed[KEY_ID_LEN..NONCE_END]); + assert!(nonces.insert(nonce), "nonce reuse detected"); + } + assert_eq!(nonces.len(), SEALS); + } + + #[test] + fn open_under_different_step_fails_replay_integrity() { + let cipher = XChaCha20Poly1305Cipher::new(0, [5u8; 32]); + let exec = ExecutionId::new(); + let sealed = cipher.seal(b"result", &aad_for(exec, 7)).unwrap(); + + let err = cipher.open(&sealed, &aad_for(exec, 8)).unwrap_err(); + assert!(matches!(err, CipherError::Authentication)); + assert!(matches!( + DurableError::from(err), + DurableError::ReplayIntegrity + )); + } + + #[test] + fn open_under_different_execution_fails_replay_integrity() { + let cipher = XChaCha20Poly1305Cipher::new(0, [6u8; 32]); + let sealed = cipher + .seal(b"result", &aad_for(ExecutionId::new(), 0)) + .unwrap(); + + let err = cipher + .open(&sealed, &aad_for(ExecutionId::new(), 0)) + .unwrap_err(); + assert!(matches!( + DurableError::from(err), + DurableError::ReplayIntegrity + )); + } + + #[test] + fn tampered_ciphertext_fails_authentication() { + let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let mut sealed = cipher.seal(b"result", &aad).unwrap(); + let last = sealed.len() - 1; + sealed[last] ^= 0xFF; + assert!(matches!( + cipher.open(&sealed, &aad).unwrap_err(), + CipherError::Authentication + )); + } + + #[test] + fn short_blob_is_malformed() { + let cipher = XChaCha20Poly1305Cipher::new(0, [0u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let err = cipher.open(&[0u8; MIN_SEALED_LEN - 1], &aad).unwrap_err(); + assert!(matches!(err, CipherError::Malformed { .. })); + assert!(matches!( + DurableError::from(err), + DurableError::Decode { .. } + )); + } + + #[test] + fn unknown_key_id_fails_closed() { + let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let mut sealed = cipher.seal(b"x", &aad).unwrap(); + sealed[0] = 200; // no key registered under id 200 + assert!(matches!( + cipher.open(&sealed, &aad).unwrap_err(), + CipherError::UnknownKeyId { key_id: 200 } + )); + } + + #[test] + fn previous_key_opens_during_rotation_window() { + // Seal under the old key (id 0), then rotate: current is id 1, previous is id 0. + let old = XChaCha20Poly1305Cipher::new(0, [1u8; 32]); + let aad = aad_for(ExecutionId::new(), 0); + let sealed = old.seal(b"in-flight", &aad).unwrap(); + + let rotated = XChaCha20Poly1305Cipher::new(1, [2u8; 32]).with_previous(0, [1u8; 32]); + // The old blob still opens via the previous key... + assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight"); + // ...while new seals use the current key-id. + assert_eq!(rotated.seal(b"new", &aad).unwrap()[0], 1); + } + + #[test] + fn from_vault_bytes_validates_length() { + assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok()); + // The cipher deliberately does not implement `Debug` (it holds key material), so match on + // the `Result` directly rather than calling `unwrap_err`. + assert!(matches!( + XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"), + Err(CipherKeyError::InvalidKeyLength { + expected: 32, + actual: 5 + }) + )); + } +} diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index a47044f9f..8498dfd57 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -77,6 +77,7 @@ pub mod context; pub mod cost; pub mod daemon; pub mod debug_dump; +pub mod durable; pub mod file_watcher; pub mod goal; pub mod instructions; diff --git a/crates/zeph-durable/src/cipher.rs b/crates/zeph-durable/src/cipher.rs new file mode 100644 index 000000000..a95e43bd7 --- /dev/null +++ b/crates/zeph-durable/src/cipher.rs @@ -0,0 +1,495 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The confidentiality and integrity boundary for journaled payloads. +//! +//! Journal payloads (step results, promise resolutions, checkpoint snapshots) are written to a +//! database file that — for shared-DB and Restate deployments — sits outside the process trust +//! boundary. This module defines the *contract* that protects them: +//! +//! - [`PayloadCipher`] — the AEAD seal/open trait. The concrete `XChaCha20-Poly1305` implementation +//! lives in a consuming crate (the binary or a `zeph-core`-side module), keyed from the vault, so +//! `zeph-durable` stays a pure Layer-0 abstraction with no cryptographic dependency (INV-1). The +//! backend receives the cipher as `Option>` at construction. +//! - [`PayloadAad`] — the associated data bound into every seal. Binding +//! `(execution_id, step_id, entry_kind, idem_key)` makes a sealed blob un-relocatable: a result +//! sealed for one step cannot be opened as the result of another step or another execution +//! (fail-closed → [`CipherError::Authentication`] → [`DurableError::ReplayIntegrity`]). +//! - [`EntryKindTag`] — a `Copy` discriminator for the entry shape, used inside the AAD so the +//! cipher never needs to see the payload-bearing [`crate::EntryKind`] itself. +//! - [`CipherError`] — seal/open failures, reported as metadata only (INV-5): no payload bytes, +//! nonces, or key material ever appear in an error. +//! - [`ensure_payload_within_limit`] — the read-side size guard (INV-11) that fails closed *before* +//! any decryption or decode is attempted. +//! +//! # Stored blob layout +//! +//! A concrete cipher MUST produce `key_id(1) || nonce(24) || ciphertext || tag(16)`. The leading +//! key-id byte selects the key during a rotation window; the 24-byte nonce is the `XChaCha20` +//! extended nonce, freshly drawn from a CSPRNG on every seal (INV-7). +//! +//! # Examples +//! +//! ``` +//! use zeph_durable::{ExecutionId, StepId}; +//! use zeph_durable::cipher::{EntryKindTag, PayloadAad}; +//! +//! // The AAD for a step result binds the execution, the step, and the entry shape. +//! let aad = PayloadAad::new(ExecutionId::new(), StepId::new(7), EntryKindTag::StepResult, None); +//! +//! // The canonical encoding is deterministic and injective — the same logical AAD always +//! // produces the same bytes, and no two distinct AADs collide. +//! assert_eq!(aad.canonical_bytes(), aad.canonical_bytes()); +//! ``` + +use crate::error::DurableError; +use crate::ids::{ExecutionId, IdempotencyKey, StepId}; + +/// Wire-format version for [`PayloadAad::canonical_bytes`]. +/// +/// Bumping this changes the associated-data encoding and is therefore a breaking change for any +/// already-sealed journal (decryption of old entries would fail authentication). It is the first +/// byte of the canonical encoding so the format is self-describing. +const AAD_FORMAT_V1: u8 = 1; + +/// Encrypts and decrypts opaque journal payloads with an AEAD construction. +/// +/// A `PayloadCipher` is the only component permitted to see plaintext payload bytes. It is injected +/// into a backend as `Option>`: `None` disables encryption (a development +/// override permitted only for a single-user local backend, see +/// [`DurableConfig::encryption_gate`](crate::DurableConfig::encryption_gate)). +/// +/// # Contract for implementors +/// +/// - [`seal`](PayloadCipher::seal) MUST draw a fresh CSPRNG nonce for every call (INV-7) and emit +/// the `key_id(1) || nonce(24) || ciphertext || tag(16)` layout. +/// - The `aad` MUST be authenticated via the AEAD's associated-data channel (not merely prepended), +/// so a tampered or relocated entry fails [`open`](PayloadCipher::open). +/// - Neither method may panic on malformed input; corruption is reported as a [`CipherError`]. +/// - Implementations are `Send + Sync` so a single cipher can be shared across the writer and +/// replay tasks behind an `Arc`. +/// +/// # Examples +/// +/// A minimal (insecure, illustrative) implementation that shows the layout discipline a real +/// cipher must follow: +/// +/// ``` +/// use std::sync::Arc; +/// use zeph_durable::cipher::{CipherError, PayloadAad, PayloadCipher}; +/// +/// struct Identity; +/// impl PayloadCipher for Identity { +/// fn seal(&self, plaintext: &[u8], _aad: &PayloadAad) -> Result, CipherError> { +/// Ok(plaintext.to_vec()) // a real cipher would AEAD-encrypt here +/// } +/// fn open(&self, sealed: &[u8], _aad: &PayloadAad) -> Result, CipherError> { +/// Ok(sealed.to_vec()) +/// } +/// } +/// +/// let cipher: Arc = Arc::new(Identity); +/// assert!(cipher.seal(b"hello", &PayloadAad::detached()).is_ok()); +/// ``` +pub trait PayloadCipher: Send + Sync { + /// Seal `plaintext` under `aad`, returning the stored blob + /// (`key_id || nonce || ciphertext || tag`). + /// + /// # Errors + /// + /// Returns [`CipherError::Authentication`] if the underlying AEAD encryption fails (an + /// unexpected condition for a correctly-sized key and nonce). + fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result, CipherError>; + + /// Open a blob previously produced by [`seal`](PayloadCipher::seal), verifying `aad`. + /// + /// # Errors + /// + /// - [`CipherError::Authentication`] if the tag does not verify under `aad` — the entry was + /// forged, moved to a different step, or replayed under a different execution. + /// - [`CipherError::Malformed`] if the blob is too short to contain the framing. + /// - [`CipherError::UnknownKeyId`] if the leading key-id selects no registered key. + fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result, CipherError>; +} + +/// A `Copy` discriminator naming the shape of a journal entry, used inside [`PayloadAad`]. +/// +/// It mirrors the variants of [`crate::EntryKind`] without their data, so the cipher can bind the +/// entry shape into the AAD without depending on the payload-bearing enum. The canonical +/// [`as_str`](EntryKindTag::as_str) value matches [`crate::EntryKind::tag`]. +/// +/// # Examples +/// +/// ``` +/// use zeph_durable::cipher::EntryKindTag; +/// +/// assert_eq!(EntryKindTag::StepResult.as_str(), "step_result"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntryKindTag { + /// A committed step result. + StepResult, + /// An exactly-once effect intent. + EffectIntent, + /// Creation of an external-completion promise. + PromiseCreated, + /// Resolution of a promise. + PromiseResolved, + /// A durable timer was armed. + TimerArmed, + /// A durable timer fired. + TimerFired, + /// A compaction checkpoint. + Checkpoint, +} + +impl EntryKindTag { + /// Return the canonical lower-snake-case tag, identical to [`crate::EntryKind::tag`]. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::StepResult => "step_result", + Self::EffectIntent => "effect_intent", + Self::PromiseCreated => "promise_created", + Self::PromiseResolved => "promise_resolved", + Self::TimerArmed => "timer_armed", + Self::TimerFired => "timer_fired", + Self::Checkpoint => "checkpoint", + } + } + + /// A stable single-byte code used in the AAD framing. + /// + /// Distinct from the variant's source order so reordering the enum cannot silently change the + /// wire format. + const fn aad_code(self) -> u8 { + match self { + Self::StepResult => 1, + Self::EffectIntent => 2, + Self::PromiseCreated => 3, + Self::PromiseResolved => 4, + Self::TimerArmed => 5, + Self::TimerFired => 6, + Self::Checkpoint => 7, + } + } +} + +/// The associated data bound into a payload seal. +/// +/// Binding the payload to its location — `(execution_id, step_id, entry_kind, idem_key)` — is what +/// makes a sealed blob un-relocatable. Moving a `StepResult` blob to a different `step_id`, or +/// replaying it under a different `execution_id`, changes the AAD and makes +/// [`PayloadCipher::open`] fail authentication (fail-closed). The fields are private; construct via +/// [`PayloadAad::new`] and read the bound encoding via [`PayloadAad::canonical_bytes`]. +/// +/// # Security +/// +/// The bound `idem_key` and the plaintext payload MUST be derived from non-secret descriptors only +/// (INV-6): resolved secret material is referenced by vault key name, never embedded here or in the +/// [`IdempotencyKey`] fingerprint. The AAD is authenticated but not encrypted, so it must never +/// carry a secret value. +/// +/// # Examples +/// +/// ``` +/// use zeph_durable::{ExecutionId, IdempotencyKey, StepId}; +/// use zeph_durable::cipher::{EntryKindTag, PayloadAad}; +/// +/// let exec = ExecutionId::new(); +/// let key = IdempotencyKey::derive(exec, StepId::new(0), b"tool:transfer"); +/// let with_key = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, Some(key)); +/// let without_key = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None); +/// +/// // The optional idempotency key is part of the binding. +/// assert_ne!(with_key.canonical_bytes(), without_key.canonical_bytes()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayloadAad { + execution_id: ExecutionId, + step_id: StepId, + entry_kind: EntryKindTag, + idem_key: Option, +} + +impl PayloadAad { + /// Construct the associated data for a payload at a known journal location. + #[must_use] + pub fn new( + execution_id: ExecutionId, + step_id: StepId, + entry_kind: EntryKindTag, + idem_key: Option, + ) -> Self { + Self { + execution_id, + step_id, + entry_kind, + idem_key, + } + } + + /// A placeholder AAD for doc examples and unit tests that do not exercise binding. + /// + /// Not for production use: every real seal MUST bind a meaningful location. + #[doc(hidden)] + #[must_use] + pub fn detached() -> Self { + Self::new( + ExecutionId::new(), + StepId::new(0), + EntryKindTag::StepResult, + None, + ) + } + + /// Encode the AAD as deterministic, injective bytes for the AEAD associated-data channel. + /// + /// Layout (fixed positions, so the encoding is injective without per-field length prefixes): + /// `version(1) || execution_id(16) || step_id_le(4) || entry_kind(1) || idem_present(1) || + /// [idem_key(32) when present]`. Every concrete [`PayloadCipher`] feeds these exact bytes to its + /// AEAD so seal and open agree on the binding. + /// + /// # Examples + /// + /// ``` + /// use zeph_durable::{ExecutionId, StepId}; + /// use zeph_durable::cipher::{EntryKindTag, PayloadAad}; + /// + /// let aad = PayloadAad::new(ExecutionId::new(), StepId::new(1), EntryKindTag::Checkpoint, None); + /// // version + 16 + 4 + 1 + 1 = 23 bytes when no idempotency key is bound. + /// assert_eq!(aad.canonical_bytes().len(), 23); + /// ``` + #[must_use] + pub fn canonical_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(23 + if self.idem_key.is_some() { 32 } else { 0 }); + out.push(AAD_FORMAT_V1); + out.extend_from_slice(self.execution_id.as_bytes()); + out.extend_from_slice(&self.step_id.value().to_le_bytes()); + out.push(self.entry_kind.aad_code()); + match &self.idem_key { + Some(key) => { + out.push(1); + out.extend_from_slice(key.as_bytes()); + } + None => out.push(0), + } + out + } +} + +/// A failure raised by a [`PayloadCipher`]. +/// +/// Like [`DurableError`], a `CipherError` carries metadata only — never payload bytes, nonces, or +/// key material (INV-5) — so it is always safe to log. The enum is `#[non_exhaustive]`: a concrete +/// cipher may surface additional failure modes in future revisions. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CipherError { + /// The AEAD tag did not verify: the entry was forged, relocated, or replayed under a different + /// execution. Maps to [`DurableError::ReplayIntegrity`]. + #[error("sealed payload failed AEAD authentication")] + Authentication, + + /// The stored blob is too short or otherwise structurally invalid before decryption. + #[error("sealed blob is malformed: {context}")] + Malformed { + /// A non-sensitive description of the structural problem. + context: &'static str, + }, + + /// The blob's leading key-id selects no key registered with the cipher (e.g. a stale key was + /// removed before its rotation window closed). + #[error("no cipher key registered for key-id {key_id}")] + UnknownKeyId { + /// The unrecognized key-id byte. + key_id: u8, + }, +} + +impl From for DurableError { + /// Lift a cipher failure into the crate-wide error, preserving fail-closed semantics. + /// + /// An authentication failure is a replay-integrity violation; a structural or key-selection + /// failure is a decode failure. Both fail closed — no plaintext is ever returned. + fn from(err: CipherError) -> Self { + match err { + CipherError::Authentication => Self::ReplayIntegrity, + CipherError::Malformed { context } => Self::Decode { context }, + CipherError::UnknownKeyId { .. } => Self::Decode { + context: "unknown cipher key-id", + }, + } + } +} + +/// Reject a payload that exceeds `max_bytes` *before* any decryption or decode is attempted. +/// +/// This is the read-side half of the `max_payload_bytes` limit (INV-11): a corrupt or hostile +/// journal entry advertising a multi-gigabyte payload is refused in O(1) — no allocation, no +/// decode, no panic — so it cannot be used to exhaust memory. The write side enforces the same +/// limit when an entry is appended. +/// +/// # Errors +/// +/// Returns [`DurableError::PayloadTooLarge`] when `len` exceeds `max_bytes`. +/// +/// # Examples +/// +/// ``` +/// use zeph_durable::cipher::ensure_payload_within_limit; +/// +/// assert!(ensure_payload_within_limit(1024, 1_048_576).is_ok()); +/// assert!(ensure_payload_within_limit(2_000_000, 1_048_576).is_err()); +/// ``` +pub fn ensure_payload_within_limit(len: usize, max_bytes: u64) -> Result<(), DurableError> { + let size = len as u64; + if size > max_bytes { + return Err(DurableError::PayloadTooLarge { + size, + max: max_bytes, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_key(exec: ExecutionId) -> IdempotencyKey { + IdempotencyKey::derive(exec, StepId::new(0), b"op") + } + + #[test] + fn entry_kind_tag_strings_are_stable() { + assert_eq!(EntryKindTag::StepResult.as_str(), "step_result"); + assert_eq!(EntryKindTag::EffectIntent.as_str(), "effect_intent"); + assert_eq!(EntryKindTag::PromiseCreated.as_str(), "promise_created"); + assert_eq!(EntryKindTag::PromiseResolved.as_str(), "promise_resolved"); + assert_eq!(EntryKindTag::TimerArmed.as_str(), "timer_armed"); + assert_eq!(EntryKindTag::TimerFired.as_str(), "timer_fired"); + assert_eq!(EntryKindTag::Checkpoint.as_str(), "checkpoint"); + } + + #[test] + fn entry_kind_tag_aad_codes_are_distinct() { + let tags = [ + EntryKindTag::StepResult, + EntryKindTag::EffectIntent, + EntryKindTag::PromiseCreated, + EntryKindTag::PromiseResolved, + EntryKindTag::TimerArmed, + EntryKindTag::TimerFired, + EntryKindTag::Checkpoint, + ]; + let mut codes: Vec = tags.iter().map(|t| t.aad_code()).collect(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), tags.len(), "every tag has a distinct AAD code"); + } + + #[test] + fn canonical_bytes_is_deterministic() { + let aad = PayloadAad::new( + ExecutionId::new(), + StepId::new(3), + EntryKindTag::StepResult, + None, + ); + assert_eq!(aad.canonical_bytes(), aad.canonical_bytes()); + } + + #[test] + fn canonical_bytes_length_matches_idem_presence() { + let exec = ExecutionId::new(); + let without = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None); + let with = PayloadAad::new( + exec, + StepId::new(0), + EntryKindTag::StepResult, + Some(sample_key(exec)), + ); + assert_eq!(without.canonical_bytes().len(), 23); + assert_eq!(with.canonical_bytes().len(), 23 + 32); + } + + #[test] + fn canonical_bytes_differs_per_field() { + let exec = ExecutionId::new(); + let other = ExecutionId::new(); + let base = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None); + + let diff_exec = PayloadAad::new(other, StepId::new(0), EntryKindTag::StepResult, None); + let diff_step = PayloadAad::new(exec, StepId::new(1), EntryKindTag::StepResult, None); + let diff_kind = PayloadAad::new(exec, StepId::new(0), EntryKindTag::PromiseResolved, None); + let diff_key = PayloadAad::new( + exec, + StepId::new(0), + EntryKindTag::StepResult, + Some(sample_key(exec)), + ); + + let base_bytes = base.canonical_bytes(); + assert_ne!(base_bytes, diff_exec.canonical_bytes()); + assert_ne!(base_bytes, diff_step.canonical_bytes()); + assert_ne!(base_bytes, diff_kind.canonical_bytes()); + assert_ne!(base_bytes, diff_key.canonical_bytes()); + } + + #[test] + fn canonical_bytes_is_versioned() { + let aad = PayloadAad::new( + ExecutionId::new(), + StepId::new(0), + EntryKindTag::StepResult, + None, + ); + assert_eq!(aad.canonical_bytes()[0], AAD_FORMAT_V1); + } + + #[test] + fn cipher_error_maps_to_durable_error_fail_closed() { + assert!(matches!( + DurableError::from(CipherError::Authentication), + DurableError::ReplayIntegrity + )); + assert!(matches!( + DurableError::from(CipherError::Malformed { context: "x" }), + DurableError::Decode { context: "x" } + )); + assert!(matches!( + DurableError::from(CipherError::UnknownKeyId { key_id: 9 }), + DurableError::Decode { .. } + )); + } + + #[test] + fn cipher_error_messages_are_metadata_only() { + // No payload bytes leak; only the structural key-id is named. + assert!( + CipherError::UnknownKeyId { key_id: 42 } + .to_string() + .contains("42") + ); + assert_eq!( + CipherError::Authentication.to_string(), + "sealed payload failed AEAD authentication" + ); + } + + #[test] + fn payload_limit_guard_fails_closed_without_panic() { + let max: u64 = 1_048_576; + assert!(ensure_payload_within_limit(0, max).is_ok()); + assert!( + ensure_payload_within_limit(1_048_576, max).is_ok(), + "exactly at the limit is ok" + ); + let err = ensure_payload_within_limit(1_048_577, max).unwrap_err(); + assert!(matches!( + err, + DurableError::PayloadTooLarge { size, max: m } if size == 1_048_577 && m == max + )); + } +} diff --git a/crates/zeph-durable/src/config.rs b/crates/zeph-durable/src/config.rs index c059857f8..4188776c8 100644 --- a/crates/zeph-durable/src/config.rs +++ b/crates/zeph-durable/src/config.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; +use crate::error::DurableError; + /// Which journal backend an execution uses. /// /// `Restate` is only meaningful when the `restate` feature and an external Restate server are @@ -95,6 +97,64 @@ impl Default for DurableConfig { } } +/// Outcome of evaluating the INV-8 AEAD requirement for a deployment. +/// +/// Returned by [`DurableConfig::encryption_gate`]. The error case +/// ([`DurableError::EncryptionRequired`]) covers the forbidden combinations; this enum distinguishes +/// the two *permitted* outcomes so the caller can act on the development-override warning. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionGate { + /// AEAD payload encryption is enabled — proceed normally. + Enabled, + /// AEAD is disabled on a single-user local backend (a development override). The caller MUST + /// emit a startup `WARN` so the weakened posture is visible in the logs. + DisabledLocalWarn, +} + +impl DurableConfig { + /// Evaluate whether the configured `encrypt_payload` setting is permitted for this deployment + /// (INV-8). + /// + /// AEAD is default-on. Disabling it is a development-only override that is permitted **only** for + /// a single-user local backend on a non-shared database. `shared_db` MUST be `true` whenever the + /// journal lives on a multi-client database (Postgres, or any file shared across processes), + /// where the DB-file trust boundary does not hold. + /// + /// # Errors + /// + /// Returns [`DurableError::EncryptionRequired`] when `encrypt_payload = false` is combined with + /// a non-local backend or a shared database. + /// + /// # Examples + /// + /// ``` + /// use zeph_durable::{DurableConfig, EncryptionGate}; + /// + /// // Default config keeps AEAD on regardless of deployment. + /// let cfg = DurableConfig::default(); + /// assert_eq!(cfg.encryption_gate(true).unwrap(), EncryptionGate::Enabled); + /// + /// // Disabling AEAD is tolerated only on a single-user local backend. + /// let dev = DurableConfig { encrypt_payload: false, ..DurableConfig::default() }; + /// assert_eq!(dev.encryption_gate(false).unwrap(), EncryptionGate::DisabledLocalWarn); + /// assert!(dev.encryption_gate(true).is_err(), "forbidden on a shared database"); + /// ``` + pub fn encryption_gate(&self, shared_db: bool) -> Result { + if self.encrypt_payload { + return Ok(EncryptionGate::Enabled); + } + if self.backend != DurableBackend::Local { + return Err(DurableError::EncryptionRequired { context: "restate" }); + } + if shared_db { + return Err(DurableError::EncryptionRequired { + context: "shared-database", + }); + } + Ok(EncryptionGate::DisabledLocalWarn) + } +} + /// Journal retention and compaction policy (`[durable.retention]`). /// /// Drives the background prune sweep, which never runs on the dispatch hot path. @@ -177,6 +237,60 @@ mod tests { assert_eq!(from_toml, DurableConfig::default()); } + #[test] + fn encryption_gate_passes_when_aead_enabled() { + let cfg = DurableConfig::default(); + assert!(cfg.encrypt_payload); + assert_eq!(cfg.encryption_gate(false).unwrap(), EncryptionGate::Enabled); + assert_eq!(cfg.encryption_gate(true).unwrap(), EncryptionGate::Enabled); + let restate = DurableConfig { + backend: DurableBackend::Restate, + ..DurableConfig::default() + }; + assert_eq!( + restate.encryption_gate(true).unwrap(), + EncryptionGate::Enabled + ); + } + + #[test] + fn encryption_gate_warns_for_local_single_user_override() { + let cfg = DurableConfig { + encrypt_payload: false, + backend: DurableBackend::Local, + ..DurableConfig::default() + }; + assert_eq!( + cfg.encryption_gate(false).unwrap(), + EncryptionGate::DisabledLocalWarn + ); + } + + #[test] + fn encryption_gate_rejects_disabled_aead_on_shared_or_restate() { + let local_shared = DurableConfig { + encrypt_payload: false, + backend: DurableBackend::Local, + ..DurableConfig::default() + }; + assert!(matches!( + local_shared.encryption_gate(true), + Err(DurableError::EncryptionRequired { + context: "shared-database" + }) + )); + + let restate = DurableConfig { + encrypt_payload: false, + backend: DurableBackend::Restate, + ..DurableConfig::default() + }; + assert!(matches!( + restate.encryption_gate(false), + Err(DurableError::EncryptionRequired { context: "restate" }) + )); + } + #[test] fn partial_table_overrides_only_named_fields() { let cfg: DurableConfig = toml::from_str( diff --git a/crates/zeph-durable/src/error.rs b/crates/zeph-durable/src/error.rs index 88c0b342b..83b4a2819 100644 --- a/crates/zeph-durable/src/error.rs +++ b/crates/zeph-durable/src/error.rs @@ -69,6 +69,18 @@ pub enum DurableError { /// The configured hard step cap. cap: u32, }, + + /// AEAD payload encryption was disabled (`encrypt_payload = false`) for a deployment where it + /// is mandatory — a non-local backend or a shared database (INV-8). The DB-file trust boundary + /// does not hold in multi-client environments, so this fails closed at startup. + #[error( + "AEAD payload encryption is required for the '{context}' deployment and cannot be disabled" + )] + EncryptionRequired { + /// A non-sensitive label for the deployment that mandates encryption (e.g. `"restate"` or + /// `"shared-database"`). + context: &'static str, + }, } #[cfg(test)] diff --git a/crates/zeph-durable/src/journal.rs b/crates/zeph-durable/src/journal.rs index 6b94b4c46..17953a605 100644 --- a/crates/zeph-durable/src/journal.rs +++ b/crates/zeph-durable/src/journal.rs @@ -15,6 +15,7 @@ use std::future::Future; use bytes::Bytes; +use crate::cipher::EntryKindTag; use crate::config::RetentionPolicy; use crate::effect::EffectClass; use crate::error::DurableError; @@ -137,21 +138,31 @@ pub enum EntryKind { } impl EntryKind { + /// Return the data-free [`EntryKindTag`] discriminator for this entry. + /// + /// This is the bridge a backend uses to build the [`PayloadAad`](crate::PayloadAad) for an + /// entry without exposing the payload to the cipher binding logic. + #[must_use] + pub fn tag_enum(&self) -> EntryKindTag { + match self { + Self::StepResult { .. } => EntryKindTag::StepResult, + Self::EffectIntent { .. } => EntryKindTag::EffectIntent, + Self::PromiseCreated { .. } => EntryKindTag::PromiseCreated, + Self::PromiseResolved { .. } => EntryKindTag::PromiseResolved, + Self::TimerArmed { .. } => EntryKindTag::TimerArmed, + Self::TimerFired { .. } => EntryKindTag::TimerFired, + Self::Checkpoint { .. } => EntryKindTag::Checkpoint, + } + } + /// Return the canonical string used in the `entry_kind` column. /// /// The `step_result` tag in particular is the predicate of the unique partial index that - /// enforces "at most one committed result per step". + /// enforces "at most one committed result per step". Delegates to [`EntryKindTag::as_str`] so + /// the column strings have a single source of truth. #[must_use] pub fn tag(&self) -> &'static str { - match self { - Self::StepResult { .. } => "step_result", - Self::EffectIntent { .. } => "effect_intent", - Self::PromiseCreated { .. } => "promise_created", - Self::PromiseResolved { .. } => "promise_resolved", - Self::TimerArmed { .. } => "timer_armed", - Self::TimerFired { .. } => "timer_fired", - Self::Checkpoint { .. } => "checkpoint", - } + self.tag_enum().as_str() } } diff --git a/crates/zeph-durable/src/lib.rs b/crates/zeph-durable/src/lib.rs index 0900c5830..2519844c3 100644 --- a/crates/zeph-durable/src/lib.rs +++ b/crates/zeph-durable/src/lib.rs @@ -25,6 +25,8 @@ //! [`IdempotencyKey`], [`PromiseId`], [`TimerId`]) and the [`ExecutionKind`] discriminator. //! - [`journal`] — the [`Journal`] trait plus the [`JournalEntry`] / [`EntryKind`] / //! [`ExecutionStatus`] data model. +//! - [`cipher`] — the [`PayloadCipher`] AEAD contract, [`PayloadAad`] binding, and the read-side +//! `max_payload` guard. The concrete cipher lives in a consuming crate (INV-1). //! - [`effect`] — the [`EffectClass`] side-effect contract referenced by journal entries. //! - [`config`] — the pure-data [`DurableConfig`] and [`RetentionPolicy`] mirroring the //! `[durable]` TOML section. @@ -55,6 +57,7 @@ mod sealed; +pub mod cipher; pub mod config; pub mod effect; pub mod error; @@ -64,7 +67,10 @@ pub mod journal; #[doc(hidden)] pub use sealed::Sealed; -pub use config::{DurableBackend, DurableConfig, RetentionPolicy}; +pub use cipher::{ + CipherError, EntryKindTag, PayloadAad, PayloadCipher, ensure_payload_within_limit, +}; +pub use config::{DurableBackend, DurableConfig, EncryptionGate, RetentionPolicy}; pub use effect::EffectClass; pub use error::DurableError; pub use ids::{ExecutionId, ExecutionKind, IdempotencyKey, JournalSeq, PromiseId, StepId, TimerId};