Skip to main content

zeph_durable/
cipher.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The confidentiality and integrity boundary for journaled payloads.
5//!
6//! Journal payloads (step results, promise resolutions, checkpoint snapshots) are written to a
7//! database file that — for shared-DB and Restate deployments — sits outside the process trust
8//! boundary. This module defines the *contract* that protects them:
9//!
10//! - [`PayloadCipher`] — the AEAD seal/open trait. The concrete `XChaCha20-Poly1305` implementation
11//!   lives in a consuming crate (the binary or a `zeph-core`-side module), keyed from the vault, so
12//!   `zeph-durable` stays a pure Layer-0 abstraction with no cryptographic dependency (INV-1). The
13//!   backend receives the cipher as `Option<Arc<dyn PayloadCipher>>` at construction.
14//! - [`PayloadAad`] — the associated data bound into every seal. Binding
15//!   `(execution_id, step_id, entry_kind, idem_key)` makes a sealed blob un-relocatable: a result
16//!   sealed for one step cannot be opened as the result of another step or another execution
17//!   (fail-closed → [`CipherError::Authentication`] → [`DurableError::ReplayIntegrity`]).
18//! - [`EntryKindTag`] — a `Copy` discriminator for the entry shape, used inside the AAD so the
19//!   cipher never needs to see the payload-bearing [`crate::EntryKind`] itself.
20//! - [`CipherError`] — seal/open failures, reported as metadata only (INV-5): no payload bytes,
21//!   nonces, or key material ever appear in an error.
22//! - [`ensure_payload_within_limit`] — the read-side size guard (INV-11) that fails closed *before*
23//!   any decryption or decode is attempted.
24//!
25//! # Stored blob layout
26//!
27//! A concrete cipher MUST produce `key_id(1) || nonce(24) || ciphertext || tag(16)`. The leading
28//! key-id byte selects the key during a rotation window; the 24-byte nonce is the `XChaCha20`
29//! extended nonce, freshly drawn from a CSPRNG on every seal (INV-7).
30//!
31//! # Examples
32//!
33//! ```
34//! use zeph_durable::{ExecutionId, StepId};
35//! use zeph_durable::cipher::{EntryKindTag, PayloadAad};
36//!
37//! // The AAD for a step result binds the execution, the step, and the entry shape.
38//! let aad = PayloadAad::new(ExecutionId::new(), StepId::new(7), EntryKindTag::StepResult, None);
39//!
40//! // The canonical encoding is deterministic and injective — the same logical AAD always
41//! // produces the same bytes, and no two distinct AADs collide.
42//! assert_eq!(aad.canonical_bytes(), aad.canonical_bytes());
43//! ```
44
45use crate::error::DurableError;
46use crate::ids::{ExecutionId, IdempotencyKey, StepId};
47
48/// Wire-format version for [`PayloadAad::canonical_bytes`].
49///
50/// Bumping this changes the associated-data encoding and is therefore a breaking change for any
51/// already-sealed journal (decryption of old entries would fail authentication). It is the first
52/// byte of the canonical encoding so the format is self-describing.
53const AAD_FORMAT_V1: u8 = 1;
54
55/// Encrypts and decrypts opaque journal payloads with an AEAD construction.
56///
57/// A `PayloadCipher` is the only component permitted to see plaintext payload bytes. It is injected
58/// into a backend as `Option<Arc<dyn PayloadCipher>>`: `None` disables encryption (a development
59/// override permitted only for a single-user local backend, see
60/// [`encryption_gate`](crate::encryption_gate)).
61///
62/// # Contract for implementors
63///
64/// - [`seal`](PayloadCipher::seal) MUST draw a fresh CSPRNG nonce for every call (INV-7) and emit
65///   the `key_id(1) || nonce(24) || ciphertext || tag(16)` layout.
66/// - The `aad` MUST be authenticated via the AEAD's associated-data channel (not merely prepended),
67///   so a tampered or relocated entry fails [`open`](PayloadCipher::open).
68/// - Neither method may panic on malformed input; corruption is reported as a [`CipherError`].
69/// - Implementations are `Send + Sync` so a single cipher can be shared across the writer and
70///   replay tasks behind an `Arc`.
71///
72/// # Examples
73///
74/// A minimal (insecure, illustrative) implementation that shows the layout discipline a real
75/// cipher must follow:
76///
77/// ```
78/// use std::sync::Arc;
79/// use zeph_durable::cipher::{CipherError, PayloadAad, PayloadCipher};
80///
81/// struct Identity;
82/// impl PayloadCipher for Identity {
83///     fn seal(&self, plaintext: &[u8], _aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
84///         Ok(plaintext.to_vec()) // a real cipher would AEAD-encrypt here
85///     }
86///     fn open(&self, sealed: &[u8], _aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
87///         Ok(sealed.to_vec())
88///     }
89/// }
90///
91/// let cipher: Arc<dyn PayloadCipher> = Arc::new(Identity);
92/// assert!(cipher.seal(b"hello", &PayloadAad::detached()).is_ok());
93/// ```
94pub trait PayloadCipher: Send + Sync {
95    /// Seal `plaintext` under `aad`, returning the stored blob
96    /// (`key_id || nonce || ciphertext || tag`).
97    ///
98    /// # Errors
99    ///
100    /// Returns [`CipherError::Authentication`] if the underlying AEAD encryption fails (an
101    /// unexpected condition for a correctly-sized key and nonce).
102    fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError>;
103
104    /// Open a blob previously produced by [`seal`](PayloadCipher::seal), verifying `aad`.
105    ///
106    /// # Errors
107    ///
108    /// - [`CipherError::Authentication`] if the tag does not verify under `aad` — the entry was
109    ///   forged, moved to a different step, or replayed under a different execution.
110    /// - [`CipherError::Malformed`] if the blob is too short to contain the framing.
111    /// - [`CipherError::UnknownKeyId`] if the leading key-id selects no registered key.
112    fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError>;
113}
114
115/// A `Copy` discriminator naming the shape of a journal entry, used inside [`PayloadAad`].
116///
117/// It mirrors the variants of [`crate::EntryKind`] without their data, so the cipher can bind the
118/// entry shape into the AAD without depending on the payload-bearing enum. The canonical
119/// [`as_str`](EntryKindTag::as_str) value matches [`crate::EntryKind::tag`].
120///
121/// # Examples
122///
123/// ```
124/// use zeph_durable::cipher::EntryKindTag;
125///
126/// assert_eq!(EntryKindTag::StepResult.as_str(), "step_result");
127/// ```
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub enum EntryKindTag {
130    /// A committed step result.
131    StepResult,
132    /// An exactly-once effect intent.
133    EffectIntent,
134    /// Creation of an external-completion promise.
135    PromiseCreated,
136    /// Resolution of a promise.
137    PromiseResolved,
138    /// A durable timer was armed.
139    TimerArmed,
140    /// A durable timer fired.
141    TimerFired,
142    /// A compaction checkpoint.
143    Checkpoint,
144}
145
146impl EntryKindTag {
147    /// Return the canonical lower-snake-case tag, identical to [`crate::EntryKind::tag`].
148    #[must_use]
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::StepResult => "step_result",
152            Self::EffectIntent => "effect_intent",
153            Self::PromiseCreated => "promise_created",
154            Self::PromiseResolved => "promise_resolved",
155            Self::TimerArmed => "timer_armed",
156            Self::TimerFired => "timer_fired",
157            Self::Checkpoint => "checkpoint",
158        }
159    }
160
161    /// A stable single-byte code used in the AAD framing.
162    ///
163    /// Distinct from the variant's source order so reordering the enum cannot silently change the
164    /// wire format.
165    const fn aad_code(self) -> u8 {
166        match self {
167            Self::StepResult => 1,
168            Self::EffectIntent => 2,
169            Self::PromiseCreated => 3,
170            Self::PromiseResolved => 4,
171            Self::TimerArmed => 5,
172            Self::TimerFired => 6,
173            Self::Checkpoint => 7,
174        }
175    }
176}
177
178/// The associated data bound into a payload seal.
179///
180/// Binding the payload to its location — `(execution_id, step_id, entry_kind, idem_key)` — is what
181/// makes a sealed blob un-relocatable. Moving a `StepResult` blob to a different `step_id`, or
182/// replaying it under a different `execution_id`, changes the AAD and makes
183/// [`PayloadCipher::open`] fail authentication (fail-closed). The fields are private; construct via
184/// [`PayloadAad::new`] and read the bound encoding via [`PayloadAad::canonical_bytes`].
185///
186/// # Security
187///
188/// The bound `idem_key` and the plaintext payload MUST be derived from non-secret descriptors only
189/// (INV-6): resolved secret material is referenced by vault key name, never embedded here or in the
190/// [`IdempotencyKey`] fingerprint. The AAD is authenticated but not encrypted, so it must never
191/// carry a secret value.
192///
193/// # Examples
194///
195/// ```
196/// use zeph_durable::{ExecutionId, IdempotencyKey, StepId};
197/// use zeph_durable::cipher::{EntryKindTag, PayloadAad};
198///
199/// let exec = ExecutionId::new();
200/// let key = IdempotencyKey::derive(exec, StepId::new(0), b"tool:transfer");
201/// let with_key = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, Some(key));
202/// let without_key = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None);
203///
204/// // The optional idempotency key is part of the binding.
205/// assert_ne!(with_key.canonical_bytes(), without_key.canonical_bytes());
206/// ```
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct PayloadAad {
209    execution_id: ExecutionId,
210    step_id: StepId,
211    entry_kind: EntryKindTag,
212    idem_key: Option<IdempotencyKey>,
213}
214
215impl PayloadAad {
216    /// Construct the associated data for a payload at a known journal location.
217    #[must_use]
218    pub fn new(
219        execution_id: ExecutionId,
220        step_id: StepId,
221        entry_kind: EntryKindTag,
222        idem_key: Option<IdempotencyKey>,
223    ) -> Self {
224        Self {
225            execution_id,
226            step_id,
227            entry_kind,
228            idem_key,
229        }
230    }
231
232    /// A placeholder AAD for doc examples and unit tests that do not exercise binding.
233    ///
234    /// Not for production use: every real seal MUST bind a meaningful location.
235    #[doc(hidden)]
236    #[must_use]
237    pub fn detached() -> Self {
238        Self::new(
239            ExecutionId::new(),
240            StepId::new(0),
241            EntryKindTag::StepResult,
242            None,
243        )
244    }
245
246    /// Encode the AAD as deterministic, injective bytes for the AEAD associated-data channel.
247    ///
248    /// Layout (fixed positions, so the encoding is injective without per-field length prefixes):
249    /// `version(1) || execution_id(16) || step_id_le(4) || entry_kind(1) || idem_present(1) ||
250    /// [idem_key(32) when present]`. Every concrete [`PayloadCipher`] feeds these exact bytes to its
251    /// AEAD so seal and open agree on the binding.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use zeph_durable::{ExecutionId, StepId};
257    /// use zeph_durable::cipher::{EntryKindTag, PayloadAad};
258    ///
259    /// let aad = PayloadAad::new(ExecutionId::new(), StepId::new(1), EntryKindTag::Checkpoint, None);
260    /// // version + 16 + 4 + 1 + 1 = 23 bytes when no idempotency key is bound.
261    /// assert_eq!(aad.canonical_bytes().len(), 23);
262    /// ```
263    #[must_use]
264    pub fn canonical_bytes(&self) -> Vec<u8> {
265        let mut out = Vec::with_capacity(23 + if self.idem_key.is_some() { 32 } else { 0 });
266        out.push(AAD_FORMAT_V1);
267        out.extend_from_slice(self.execution_id.as_bytes());
268        out.extend_from_slice(&self.step_id.value().to_le_bytes());
269        out.push(self.entry_kind.aad_code());
270        match &self.idem_key {
271            Some(key) => {
272                out.push(1);
273                out.extend_from_slice(key.as_bytes());
274            }
275            None => out.push(0),
276        }
277        out
278    }
279}
280
281/// A failure raised by a [`PayloadCipher`].
282///
283/// Like [`DurableError`], a `CipherError` carries metadata only — never payload bytes, nonces, or
284/// key material (INV-5) — so it is always safe to log. The enum is `#[non_exhaustive]`: a concrete
285/// cipher may surface additional failure modes in future revisions.
286#[derive(Debug, thiserror::Error)]
287#[non_exhaustive]
288pub enum CipherError {
289    /// The AEAD tag did not verify: the entry was forged, relocated, or replayed under a different
290    /// execution. Maps to [`DurableError::ReplayIntegrity`].
291    #[error("sealed payload failed AEAD authentication")]
292    Authentication,
293
294    /// The stored blob is too short or otherwise structurally invalid before decryption.
295    #[error("sealed blob is malformed: {context}")]
296    Malformed {
297        /// A non-sensitive description of the structural problem.
298        context: &'static str,
299    },
300
301    /// The blob's leading key-id selects no key registered with the cipher (e.g. a stale key was
302    /// removed before its rotation window closed).
303    #[error("no cipher key registered for key-id {key_id}")]
304    UnknownKeyId {
305        /// The unrecognized key-id byte.
306        key_id: u8,
307    },
308}
309
310impl From<CipherError> for DurableError {
311    /// Lift a cipher failure into the crate-wide error, preserving fail-closed semantics.
312    ///
313    /// An authentication failure is a replay-integrity violation; a structural or key-selection
314    /// failure is a decode failure. Both fail closed — no plaintext is ever returned.
315    fn from(err: CipherError) -> Self {
316        match err {
317            CipherError::Authentication => Self::ReplayIntegrity,
318            CipherError::Malformed { context } => Self::Decode { context },
319            CipherError::UnknownKeyId { .. } => Self::Decode {
320                context: "unknown cipher key-id",
321            },
322        }
323    }
324}
325
326/// Reject a payload that exceeds `max_bytes` *before* any decryption or decode is attempted.
327///
328/// This is the read-side half of the `max_payload_bytes` limit (INV-11): a corrupt or hostile
329/// journal entry advertising a multi-gigabyte payload is refused in O(1) — no allocation, no
330/// decode, no panic — so it cannot be used to exhaust memory. The write side enforces the same
331/// limit when an entry is appended.
332///
333/// # Errors
334///
335/// Returns [`DurableError::PayloadTooLarge`] when `len` exceeds `max_bytes`.
336///
337/// # Examples
338///
339/// ```
340/// use zeph_durable::cipher::ensure_payload_within_limit;
341///
342/// assert!(ensure_payload_within_limit(1024, 1_048_576).is_ok());
343/// assert!(ensure_payload_within_limit(2_000_000, 1_048_576).is_err());
344/// ```
345pub fn ensure_payload_within_limit(len: usize, max_bytes: u64) -> Result<(), DurableError> {
346    let size = len as u64;
347    if size > max_bytes {
348        return Err(DurableError::PayloadTooLarge {
349            size,
350            max: max_bytes,
351        });
352    }
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::assert_matches;
360
361    fn sample_key(exec: ExecutionId) -> IdempotencyKey {
362        IdempotencyKey::derive(exec, StepId::new(0), b"op")
363    }
364
365    #[test]
366    fn entry_kind_tag_strings_are_stable() {
367        assert_eq!(EntryKindTag::StepResult.as_str(), "step_result");
368        assert_eq!(EntryKindTag::EffectIntent.as_str(), "effect_intent");
369        assert_eq!(EntryKindTag::PromiseCreated.as_str(), "promise_created");
370        assert_eq!(EntryKindTag::PromiseResolved.as_str(), "promise_resolved");
371        assert_eq!(EntryKindTag::TimerArmed.as_str(), "timer_armed");
372        assert_eq!(EntryKindTag::TimerFired.as_str(), "timer_fired");
373        assert_eq!(EntryKindTag::Checkpoint.as_str(), "checkpoint");
374    }
375
376    #[test]
377    fn entry_kind_tag_aad_codes_are_distinct() {
378        let tags = [
379            EntryKindTag::StepResult,
380            EntryKindTag::EffectIntent,
381            EntryKindTag::PromiseCreated,
382            EntryKindTag::PromiseResolved,
383            EntryKindTag::TimerArmed,
384            EntryKindTag::TimerFired,
385            EntryKindTag::Checkpoint,
386        ];
387        let mut codes: Vec<u8> = tags.iter().map(|t| t.aad_code()).collect();
388        codes.sort_unstable();
389        codes.dedup();
390        assert_eq!(codes.len(), tags.len(), "every tag has a distinct AAD code");
391    }
392
393    #[test]
394    fn canonical_bytes_is_deterministic() {
395        let aad = PayloadAad::new(
396            ExecutionId::new(),
397            StepId::new(3),
398            EntryKindTag::StepResult,
399            None,
400        );
401        assert_eq!(aad.canonical_bytes(), aad.canonical_bytes());
402    }
403
404    #[test]
405    fn canonical_bytes_length_matches_idem_presence() {
406        let exec = ExecutionId::new();
407        let without = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None);
408        let with = PayloadAad::new(
409            exec,
410            StepId::new(0),
411            EntryKindTag::StepResult,
412            Some(sample_key(exec)),
413        );
414        assert_eq!(without.canonical_bytes().len(), 23);
415        assert_eq!(with.canonical_bytes().len(), 23 + 32);
416    }
417
418    #[test]
419    fn canonical_bytes_differs_per_field() {
420        let exec = ExecutionId::new();
421        let other = ExecutionId::new();
422        let base = PayloadAad::new(exec, StepId::new(0), EntryKindTag::StepResult, None);
423
424        let diff_exec = PayloadAad::new(other, StepId::new(0), EntryKindTag::StepResult, None);
425        let diff_step = PayloadAad::new(exec, StepId::new(1), EntryKindTag::StepResult, None);
426        let diff_kind = PayloadAad::new(exec, StepId::new(0), EntryKindTag::PromiseResolved, None);
427        let diff_key = PayloadAad::new(
428            exec,
429            StepId::new(0),
430            EntryKindTag::StepResult,
431            Some(sample_key(exec)),
432        );
433
434        let base_bytes = base.canonical_bytes();
435        assert_ne!(base_bytes, diff_exec.canonical_bytes());
436        assert_ne!(base_bytes, diff_step.canonical_bytes());
437        assert_ne!(base_bytes, diff_kind.canonical_bytes());
438        assert_ne!(base_bytes, diff_key.canonical_bytes());
439    }
440
441    #[test]
442    fn canonical_bytes_is_versioned() {
443        let aad = PayloadAad::new(
444            ExecutionId::new(),
445            StepId::new(0),
446            EntryKindTag::StepResult,
447            None,
448        );
449        assert_eq!(aad.canonical_bytes()[0], AAD_FORMAT_V1);
450    }
451
452    #[test]
453    fn cipher_error_maps_to_durable_error_fail_closed() {
454        assert_matches!(
455            DurableError::from(CipherError::Authentication),
456            DurableError::ReplayIntegrity
457        );
458        assert_matches!(
459            DurableError::from(CipherError::Malformed { context: "x" }),
460            DurableError::Decode { context: "x" }
461        );
462        assert_matches!(
463            DurableError::from(CipherError::UnknownKeyId { key_id: 9 }),
464            DurableError::Decode { .. }
465        );
466    }
467
468    #[test]
469    fn cipher_error_messages_are_metadata_only() {
470        // No payload bytes leak; only the structural key-id is named.
471        assert!(
472            CipherError::UnknownKeyId { key_id: 42 }
473                .to_string()
474                .contains("42")
475        );
476        assert_eq!(
477            CipherError::Authentication.to_string(),
478            "sealed payload failed AEAD authentication"
479        );
480    }
481
482    #[test]
483    fn payload_limit_guard_fails_closed_without_panic() {
484        let max: u64 = 1_048_576;
485        assert!(ensure_payload_within_limit(0, max).is_ok());
486        assert!(
487            ensure_payload_within_limit(1_048_576, max).is_ok(),
488            "exactly at the limit is ok"
489        );
490        let err = ensure_payload_within_limit(1_048_577, max).unwrap_err();
491        assert_matches!(
492            err,
493            DurableError::PayloadTooLarge { size, max: m } if size == 1_048_577 && m == max
494        );
495    }
496}