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}