use std::time::{Duration, SystemTime};
use serde::Serialize;
use crate::envelope;
pub(crate) struct SessionState<T> {
pub(crate) payload: Option<T>,
pub(crate) issued_at: SystemTime,
pub(crate) original_plaintext_hash: Option<[u8; 32]>,
pub(crate) decrypt_key_index: Option<usize>,
pub(crate) mutated: bool,
pub(crate) needs_rewrite: bool,
}
pub(crate) struct DecodedCookie<T> {
pub(crate) payload: T,
pub(crate) issued_at: SystemTime,
pub(crate) plaintext_hash: [u8; 32],
pub(crate) decrypt_key_index: usize,
}
impl<T> std::fmt::Debug for SessionState<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionState")
.field("payload", &self.payload.as_ref().map(|_| "<redacted>"))
.field("issued_at", &self.issued_at)
.field(
"original_plaintext_hash",
&self.original_plaintext_hash.is_some(),
)
.field("decrypt_key_index", &self.decrypt_key_index)
.field("mutated", &self.mutated)
.field("needs_rewrite", &self.needs_rewrite)
.finish()
}
}
impl<T> std::fmt::Debug for DecodedCookie<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DecodedCookie")
.field("payload", &"<redacted>")
.field("issued_at", &self.issued_at)
.field("plaintext_hash", &"<32 bytes>")
.field("decrypt_key_index", &self.decrypt_key_index)
.finish()
}
}
#[derive(Debug)]
pub(crate) enum RewriteAction {
None,
Emit {
plaintext: Vec<u8>,
issued_at: SystemTime,
},
Delete,
}
impl<T> SessionState<T> {
pub(crate) fn new_empty(now: SystemTime) -> Self {
Self {
payload: None,
issued_at: now,
original_plaintext_hash: None,
decrypt_key_index: None,
mutated: false,
needs_rewrite: false,
}
}
pub(crate) fn from_decrypt(
decoded: Option<DecodedCookie<T>>,
max_age: Duration,
now: SystemTime,
) -> Self {
match decoded {
None => Self::new_empty(now),
Some(d) => {
let expired = match d.issued_at.checked_add(max_age) {
Some(expiry) => now > expiry,
None => false,
};
if expired {
Self {
payload: None,
issued_at: now,
original_plaintext_hash: Some(d.plaintext_hash),
decrypt_key_index: Some(d.decrypt_key_index),
mutated: false,
needs_rewrite: true,
}
} else {
Self {
payload: Some(d.payload),
issued_at: d.issued_at,
original_plaintext_hash: Some(d.plaintext_hash),
decrypt_key_index: Some(d.decrypt_key_index),
mutated: false,
needs_rewrite: d.decrypt_key_index != 0,
}
}
}
}
}
}
pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] {
let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
let mut out = [0u8; 32];
out.copy_from_slice(digest.as_ref());
out
}
pub(crate) fn should_rewrite<T>(
state: &SessionState<T>,
max_age: Duration,
refresh_after: Option<Duration>,
now: SystemTime,
) -> RewriteAction
where
T: Serialize,
{
let Some(payload) = state.payload.as_ref() else {
return if state.original_plaintext_hash.is_some() {
RewriteAction::Delete
} else {
RewriteAction::None
};
};
let Ok(payload_json) = serde_json::to_vec(payload) else {
return RewriteAction::None;
};
let base_issued_at = if state.original_plaintext_hash.is_none() {
now
} else {
state.issued_at
};
let mut effective_issued_at = base_issued_at;
let mut refresh_fired = false;
if let Some(threshold) = refresh_after
&& let Ok(age) = now.duration_since(base_issued_at)
&& age > threshold
&& age < max_age
{
effective_issued_at = now;
refresh_fired = true;
}
let candidate = envelope::encode_envelope(effective_issued_at, &payload_json);
let candidate_hash = sha256(&candidate);
let hash_changed = match state.original_plaintext_hash {
Some(h) => h != candidate_hash,
None => true,
};
if state.needs_rewrite || refresh_fired || hash_changed {
RewriteAction::Emit {
plaintext: candidate,
issued_at: effective_issued_at,
}
} else {
RewriteAction::None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envelope;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct UserPayload {
id: u64,
name: String,
}
fn fixed_time(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
}
fn decoded(
payload: UserPayload,
issued_secs: u64,
key_idx: usize,
) -> DecodedCookie<UserPayload> {
let payload_json = serde_json::to_vec(&payload).expect("UserPayload serializes cleanly");
let layer1 = envelope::encode_envelope(fixed_time(issued_secs), &payload_json);
DecodedCookie {
payload,
issued_at: fixed_time(issued_secs),
plaintext_hash: sha256(&layer1),
decrypt_key_index: key_idx,
}
}
fn sample_payload() -> UserPayload {
UserPayload {
id: 42,
name: "alice".into(),
}
}
const DAY: Duration = Duration::from_secs(24 * 3_600);
#[test]
fn new_empty_has_all_fields_in_no_session_configuration() {
let now = fixed_time(1_000_000);
let state: SessionState<UserPayload> = SessionState::new_empty(now);
assert!(state.payload.is_none());
assert_eq!(state.issued_at, now);
assert!(state.original_plaintext_hash.is_none());
assert!(state.decrypt_key_index.is_none());
assert!(!state.mutated);
assert!(!state.needs_rewrite);
}
#[test]
fn from_decrypt_no_cookie_produces_empty_state() {
let now = fixed_time(1_000_000);
let state: SessionState<UserPayload> = SessionState::from_decrypt(None, DAY, now);
assert!(state.payload.is_none());
assert_eq!(state.issued_at, now);
assert!(state.original_plaintext_hash.is_none());
assert!(state.decrypt_key_index.is_none());
assert!(!state.mutated);
assert!(!state.needs_rewrite);
}
#[test]
fn from_decrypt_valid_cookie_preserves_payload_and_issued_at() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600; let payload = sample_payload();
let d = decoded(payload.clone(), issued_secs, 0);
let expected_hash = d.plaintext_hash;
let state = SessionState::from_decrypt(Some(d), DAY, now);
assert_eq!(state.payload, Some(payload));
assert_eq!(state.issued_at, fixed_time(issued_secs));
assert_eq!(state.original_plaintext_hash, Some(expected_hash));
assert_eq!(state.decrypt_key_index, Some(0));
assert!(!state.mutated);
assert!(!state.needs_rewrite);
}
#[test]
fn from_decrypt_fallback_key_sets_needs_rewrite_seshcookie_rs_ac4_3() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600; let payload = sample_payload();
for idx in [1usize, 2] {
let d = decoded(payload.clone(), issued_secs, idx);
let state = SessionState::from_decrypt(Some(d), DAY, now);
assert_eq!(
state.payload,
Some(payload.clone()),
"fallback decrypt must still surface the payload (idx {idx})"
);
assert_eq!(
state.decrypt_key_index,
Some(idx),
"fallback index must be recorded (idx {idx})"
);
assert!(
state.needs_rewrite,
"non-primary index {idx} must set needs_rewrite"
);
assert!(
!state.mutated,
"from_decrypt must not pre-set the handler-mutation flag"
);
}
}
#[test]
fn from_decrypt_expired_cookie_sets_delete_markers_seshcookie_rs_ac2_2() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 25 * 3_600;
let d = decoded(sample_payload(), issued_secs, 0);
let expected_hash = d.plaintext_hash;
let expected_idx = d.decrypt_key_index;
let state = SessionState::from_decrypt(Some(d), DAY, now);
assert!(
state.payload.is_none(),
"expired cookie must not surface payload"
);
assert_eq!(
state.original_plaintext_hash,
Some(expected_hash),
"expired cookie must record original hash for the delete path"
);
assert_eq!(
state.decrypt_key_index,
Some(expected_idx),
"expired cookie must record the index that decrypted it"
);
assert!(
state.needs_rewrite,
"expired cookie must force a rewrite (delete emission)"
);
}
#[test]
fn from_decrypt_at_expiry_boundary_not_expired() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 24 * 3_600;
let payload = sample_payload();
let d = decoded(payload.clone(), issued_secs, 0);
let state = SessionState::from_decrypt(Some(d), DAY, now);
assert_eq!(state.payload, Some(payload));
assert!(!state.needs_rewrite);
}
#[test]
fn from_decrypt_one_second_past_expiry_is_expired_seshcookie_rs_ac2_2() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - (24 * 3_600 + 1);
let d = decoded(sample_payload(), issued_secs, 0);
let state = SessionState::from_decrypt(Some(d), DAY, now);
assert!(state.payload.is_none());
assert!(state.needs_rewrite);
}
#[test]
fn from_decrypt_overflow_treated_as_non_expired() {
let now = fixed_time(1_000_000);
let issued_secs = 1; let payload = sample_payload();
let d = decoded(payload.clone(), issued_secs, 0);
let state = SessionState::from_decrypt(Some(d), Duration::MAX, now);
assert_eq!(state.payload, Some(payload));
assert!(!state.needs_rewrite);
}
#[test]
fn sha256_matches_known_test_vector() {
let expected: [u8; 32] = [
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
0xf2, 0x00, 0x15, 0xad,
];
assert_eq!(sha256(b"abc"), expected);
}
#[test]
fn session_state_debug_redacts_payload_and_does_not_require_t_debug() {
struct NoDebugPayload(#[allow(dead_code)] u32);
let now = fixed_time(1_000_000);
let state: SessionState<NoDebugPayload> = SessionState {
payload: Some(NoDebugPayload(7)),
issued_at: now,
original_plaintext_hash: Some([0u8; 32]),
decrypt_key_index: Some(0),
mutated: true,
needs_rewrite: false,
};
let s = format!("{state:?}");
assert!(
s.contains("<redacted>"),
"Debug output should redact payload bytes: {s}"
);
assert!(
!s.contains('7'),
"payload value must not appear in Debug output: {s}"
);
}
#[test]
fn decoded_cookie_debug_redacts_payload() {
struct NoDebugPayload(#[allow(dead_code)] u32);
let cookie = DecodedCookie {
payload: NoDebugPayload(99),
issued_at: fixed_time(1_000_000),
plaintext_hash: [0u8; 32],
decrypt_key_index: 1,
};
let s = format!("{cookie:?}");
assert!(
s.contains("<redacted>"),
"Debug output should redact payload: {s}"
);
assert!(
!s.contains("99"),
"payload value must not appear in Debug output: {s}"
);
}
fn state_from_valid_cookie(
payload: UserPayload,
issued_secs: u64,
key_idx: usize,
max_age: Duration,
now: SystemTime,
) -> SessionState<UserPayload> {
SessionState::from_decrypt(Some(decoded(payload, issued_secs, key_idx)), max_age, now)
}
const HOUR: Duration = Duration::from_secs(3_600);
fn decode_emitted(plaintext: &[u8]) -> (SystemTime, UserPayload) {
let (ts, payload_bytes) =
envelope::decode_envelope(plaintext).expect("emitted plaintext must decode");
let payload: UserPayload = serde_json::from_slice(&payload_bytes)
.expect("payload bytes must parse as UserPayload");
(ts, payload)
}
#[test]
fn should_rewrite_read_only_emits_none_seshcookie_rs_ac3_1() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::None),
"read-only handler must not trigger a Set-Cookie: got {action:?}"
);
}
#[test]
fn should_rewrite_insert_same_value_suppressed_by_hash_seshcookie_rs_ac3_2() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let mut state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::None),
"insert(same_value) must be suppressed by hash-compare: got {action:?}"
);
}
#[test]
fn should_rewrite_insert_different_value_emits_seshcookie_rs_ac3_3() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let original_issued_at = fixed_time(issued_secs);
let mut state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let new_payload = UserPayload {
id: 100,
name: "bob".into(),
};
state.payload = Some(new_payload.clone());
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
match action {
RewriteAction::Emit {
plaintext,
issued_at,
} => {
assert_eq!(
issued_at, original_issued_at,
"data-change rewrite must preserve original issued_at"
);
let (decoded_ts, decoded_payload) = decode_emitted(&plaintext);
assert_eq!(decoded_ts, original_issued_at);
assert_eq!(decoded_payload, new_payload);
}
other => panic!("expected Emit, got {other:?}"),
}
}
#[test]
fn should_rewrite_modify_no_op_suppressed_by_hash_seshcookie_rs_ac3_4() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let mut state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::None),
"modify(no-op) must be suppressed by hash-compare: got {action:?}"
);
}
#[test]
fn should_rewrite_clear_on_valid_cookie_returns_delete_seshcookie_rs_ac3_5() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let mut state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
state.payload = None;
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::Delete),
"clear() on valid cookie must request delete: got {action:?}"
);
}
#[test]
fn should_rewrite_clear_on_no_cookie_returns_none_seshcookie_rs_ac3_6() {
let now = fixed_time(1_000_000);
let mut state: SessionState<UserPayload> = SessionState::from_decrypt(None, DAY, now);
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::None),
"clear() with no incoming cookie must not emit anything: got {action:?}"
);
}
#[test]
fn should_rewrite_rotation_emits_with_preserved_issued_at_seshcookie_rs_ac4_3_ac4_4() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600;
let original_issued_at = fixed_time(issued_secs);
let payload = sample_payload();
let state = state_from_valid_cookie(payload.clone(), issued_secs, 2, DAY, now);
assert!(
state.needs_rewrite,
"from_decrypt must set needs_rewrite for non-primary key index"
);
let action = should_rewrite(&state, DAY, None, now);
match action {
RewriteAction::Emit {
plaintext,
issued_at,
} => {
assert_eq!(
issued_at, original_issued_at,
"rotation-only rewrite must preserve issued_at (AC4.4)"
);
let (decoded_ts, decoded_payload) = decode_emitted(&plaintext);
assert_eq!(decoded_ts, original_issued_at);
assert_eq!(decoded_payload, payload);
}
other => panic!("expected Emit, got {other:?}"),
}
}
#[test]
fn should_rewrite_refresh_none_no_emission_at_23h_seshcookie_rs_ac8_1() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 23 * 3_600;
let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, None, now);
assert!(
matches!(action, RewriteAction::None),
"refresh_after=None must never trigger a refresh rewrite: got {action:?}"
);
}
#[test]
fn should_rewrite_refresh_within_window_no_emission_seshcookie_rs_ac8_2() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 30 * 60; let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
assert!(
matches!(action, RewriteAction::None),
"age below refresh threshold must not emit: got {action:?}"
);
}
#[test]
fn should_rewrite_refresh_threshold_exceeded_emits_bumped_issued_at_seshcookie_rs_ac8_3() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 2 * 3_600; let payload = sample_payload();
let state = state_from_valid_cookie(payload.clone(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
match action {
RewriteAction::Emit {
plaintext,
issued_at,
} => {
assert_eq!(
issued_at, now,
"refresh-fired rewrite must bump issued_at to now"
);
let (decoded_ts, decoded_payload) = decode_emitted(&plaintext);
assert_eq!(decoded_ts, now);
assert_eq!(decoded_payload, payload);
}
other => panic!("expected Emit, got {other:?}"),
}
}
#[test]
fn should_rewrite_refresh_plus_rotation_emits_bumped_issued_at_seshcookie_rs_ac8_4() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 2 * 3_600; let payload = sample_payload();
let state = state_from_valid_cookie(payload.clone(), issued_secs, 1, DAY, now);
assert!(
state.needs_rewrite,
"rotation-key cookie must already carry needs_rewrite"
);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
match action {
RewriteAction::Emit {
plaintext,
issued_at,
} => {
assert_eq!(
issued_at, now,
"simultaneous refresh+rotation must use bumped issued_at"
);
let (decoded_ts, decoded_payload) = decode_emitted(&plaintext);
assert_eq!(decoded_ts, now);
assert_eq!(decoded_payload, payload);
}
other => panic!("expected Emit, got {other:?}"),
}
}
#[test]
fn should_rewrite_25h_old_with_refresh_returns_delete_seshcookie_rs_ac8_5() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 25 * 3_600;
let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
assert!(state.payload.is_none(), "expired cookie must drop payload");
assert!(
state.original_plaintext_hash.is_some(),
"expired cookie must record hash for the delete path"
);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
assert!(
matches!(action, RewriteAction::Delete),
"expired session must return Delete regardless of refresh policy: got {action:?}"
);
}
#[test]
fn should_rewrite_new_session_from_insert_emits_with_now_issued_at() {
let now = fixed_time(1_000_000);
let mut state: SessionState<UserPayload> = SessionState::from_decrypt(None, DAY, now);
let payload = sample_payload();
state.payload = Some(payload.clone());
state.mutated = true;
let action = should_rewrite(&state, DAY, None, now);
match action {
RewriteAction::Emit {
plaintext,
issued_at,
} => {
assert_eq!(issued_at, now, "new session must use now as issued_at");
let (decoded_ts, decoded_payload) = decode_emitted(&plaintext);
assert_eq!(decoded_ts, now);
assert_eq!(decoded_payload, payload);
}
other => panic!("expected Emit, got {other:?}"),
}
}
#[test]
fn should_rewrite_refresh_at_exact_threshold_does_not_fire() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 3_600; let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
assert!(
matches!(action, RewriteAction::None),
"age == threshold must not fire refresh: got {action:?}"
);
}
#[test]
fn should_rewrite_refresh_at_exact_max_age_does_not_fire() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 - 24 * 3_600;
let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
assert!(state.payload.is_some());
let action = should_rewrite(&state, DAY, Some(HOUR), now);
assert!(
matches!(action, RewriteAction::None),
"age == max_age must not fire refresh: got {action:?}"
);
}
#[test]
fn should_rewrite_now_before_issued_at_skips_refresh() {
let now = fixed_time(1_000_000);
let issued_secs = 1_000_000 + 3_600;
let state = state_from_valid_cookie(sample_payload(), issued_secs, 0, DAY, now);
let action = should_rewrite(&state, DAY, Some(HOUR), now);
assert!(
matches!(action, RewriteAction::None),
"now < issued_at must skip refresh and not emit: got {action:?}"
);
}
}