use crate::error::DurableError;
use crate::ids::{ExecutionId, IdempotencyKey, StepId};
const AAD_FORMAT_V1: u8 = 1;
pub trait PayloadCipher: Send + Sync {
fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError>;
fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntryKindTag {
StepResult,
EffectIntent,
PromiseCreated,
PromiseResolved,
TimerArmed,
TimerFired,
Checkpoint,
}
impl EntryKindTag {
#[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",
}
}
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,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadAad {
execution_id: ExecutionId,
step_id: StepId,
entry_kind: EntryKindTag,
idem_key: Option<IdempotencyKey>,
}
impl PayloadAad {
#[must_use]
pub fn new(
execution_id: ExecutionId,
step_id: StepId,
entry_kind: EntryKindTag,
idem_key: Option<IdempotencyKey>,
) -> Self {
Self {
execution_id,
step_id,
entry_kind,
idem_key,
}
}
#[doc(hidden)]
#[must_use]
pub fn detached() -> Self {
Self::new(
ExecutionId::new(),
StepId::new(0),
EntryKindTag::StepResult,
None,
)
}
#[must_use]
pub fn canonical_bytes(&self) -> Vec<u8> {
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
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CipherError {
#[error("sealed payload failed AEAD authentication")]
Authentication,
#[error("sealed blob is malformed: {context}")]
Malformed {
context: &'static str,
},
#[error("no cipher key registered for key-id {key_id}")]
UnknownKeyId {
key_id: u8,
},
}
impl From<CipherError> for DurableError {
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",
},
}
}
}
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::*;
use std::assert_matches;
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<u8> = 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() {
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
);
}
}