seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
//! Layer-1 plaintext codec: `version || issued_at || payload_json`.
//!
//! pattern: Functional Core
//!
//! The format version byte and the `issued_at` timestamp live inside the AEAD-authenticated
//! plaintext so they cannot be tampered with or forged by the client. An attacker cannot
//! extend their own session by editing `Max-Age` bytes, and a future v2 server cannot be
//! tricked into v1 parsing by replaying old bytes.
//!
//! This module is part of the Functional Core: it performs pure data
//! transformation between a `(SystemTime, payload_json)` pair and the
//! authenticated plaintext bytes the AEAD layer seals. There is no I/O, no
//! randomness, and no wall-clock read; the caller supplies `issued_at` from
//! its own clock or a test fixture.
//!
//! ## Wire layout
//!
//! ```text
//!     +-------------------+----------------------------------+----------------+
//!     |  format_version   |  issued_at (i64 LE seconds)      |  payload_json  |
//!     |     (1 byte)      |          (8 bytes)               |   (variable)   |
//!     +-------------------+----------------------------------+----------------+
//! ```

use std::time::{Duration, SystemTime, UNIX_EPOCH};

/// Layer-1 plaintext format version. Bumping this byte is the sole way to
/// evolve the authenticated envelope format.
pub(crate) const FORMAT_VERSION: u8 = 1;

/// Length of the envelope header in bytes: 1-byte version plus 8-byte
/// little-endian `i64` issued-at-seconds.
pub(crate) const HEADER_LEN: usize = 9;

/// Errors returned by [`decode_envelope`] when the input bytes do not match
/// the layer-1 plaintext shape this module produces. These never escape the
/// crate; Phase 3 maps them to "session payload absent" without surfacing an
/// HTTP-level error.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum EnvelopeError {
    /// Input was shorter than [`HEADER_LEN`] bytes — there is not enough data
    /// to decode even the version + issued-at header.
    TooShort,
    /// The version byte did not equal [`FORMAT_VERSION`]. The actual byte is
    /// carried for diagnostics; in production this surfaces as `payload =
    /// None` regardless of the inner value.
    BadVersion(u8),
}

/// Encode a layer-1 plaintext from an issued-at time and a serialized payload.
///
/// The output is `format_version(1B) || issued_at_secs(i64 LE) || payload_json`.
/// `issued_at` is converted to whole seconds since [`UNIX_EPOCH`]:
///
/// - At or after the epoch, the result is the non-negative number of seconds,
///   clamped to [`i64::MAX`] for absurdly far-future times that overflow `i64`.
/// - Before the epoch, the result is the negation of the elapsed seconds,
///   clamped to [`i64::MIN`] for the (also unreachable in practice) deep-past
///   case. Pre-epoch handling exists so the codec is total: any
///   [`SystemTime`] value the caller supplies round-trips through
///   [`decode_envelope`] rather than panicking.
///
/// In production all `issued_at` values come from `SystemTime::now()` or a
/// recent test clock, so the clamping is a defensive fallback rather than an
/// observable behavior.
pub(crate) fn encode_envelope(issued_at: SystemTime, payload_json: &[u8]) -> Vec<u8> {
    let secs: i64 = match issued_at.duration_since(UNIX_EPOCH) {
        Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
        Err(e) => {
            // Pre-epoch SystemTime: duration_since reports the backwards
            // delta. Clamp the magnitude to i64::MAX so the subsequent
            // negation cannot overflow; checked_neg falls back to i64::MIN
            // only on the i64::MAX corner case after clamping (which itself
            // is unreachable in practice).
            let backwards = i64::try_from(e.duration().as_secs()).unwrap_or(i64::MAX);
            backwards.checked_neg().unwrap_or(i64::MIN)
        }
    };

    let mut out = Vec::with_capacity(HEADER_LEN + payload_json.len());
    out.push(FORMAT_VERSION);
    out.extend_from_slice(&secs.to_le_bytes());
    out.extend_from_slice(payload_json);
    out
}

/// Decode a layer-1 plaintext into `(issued_at, payload_json)`.
///
/// `payload_json` is returned as a freshly allocated `Vec<u8>` because the
/// upstream caller (the codec layer in Phase 1's Task 5) reuses its
/// ciphertext buffer for subsequent requests; copying out severs the
/// dependency on that buffer's lifetime.
///
/// # Errors
///
/// - [`EnvelopeError::TooShort`] when `bytes.len() < HEADER_LEN`.
/// - [`EnvelopeError::BadVersion`] when `bytes[0]` is not [`FORMAT_VERSION`].
pub(crate) fn decode_envelope(bytes: &[u8]) -> Result<(SystemTime, Vec<u8>), EnvelopeError> {
    if bytes.len() < HEADER_LEN {
        return Err(EnvelopeError::TooShort);
    }
    if bytes[0] != FORMAT_VERSION {
        return Err(EnvelopeError::BadVersion(bytes[0]));
    }

    let secs_bytes: [u8; 8] = bytes[1..HEADER_LEN]
        .try_into()
        .expect("slice length is HEADER_LEN - 1 == 8, conversion is infallible");
    let secs = i64::from_le_bytes(secs_bytes);

    let issued_at = if secs >= 0 {
        UNIX_EPOCH + Duration::from_secs(secs as u64)
    } else {
        // `unsigned_abs()` yields u64 even for `i64::MIN` without overflow;
        // a plain `-secs` would panic for the i64::MIN case.
        UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs())
    };
    let payload = bytes[HEADER_LEN..].to_vec();
    Ok((issued_at, payload))
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- seshcookie-rs.AC2.1: issued_at round-trip preserves exact value ---------

    /// seshcookie-rs.AC2.1 (envelope portion): a session issued at time T is
    /// still valid at T + max_age - 1s. The freshness comparison happens in
    /// Phase 2; the envelope's contribution is to preserve `issued_at` byte-
    /// for-byte across encode/decode so Phase 2 can compare against the exact
    /// original value.
    #[test]
    fn round_trip_preserves_issued_at_exactly_ac2_1() {
        let issued_at = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
        let payload = b"some-payload-bytes";
        let encoded = encode_envelope(issued_at, payload);
        let (decoded_time, decoded_payload) =
            decode_envelope(&encoded).expect("encoded envelope must decode");
        assert_eq!(decoded_time, issued_at);
        assert_eq!(decoded_payload, payload);
    }

    /// seshcookie-rs.AC2.1 (envelope portion): round-trip preserves several
    /// representative `issued_at` values (epoch, epoch + 1 year, far future).
    #[test]
    fn round_trip_preserves_issued_at_across_representative_times_ac2_1() {
        const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60;
        let cases = [
            UNIX_EPOCH,
            UNIX_EPOCH + Duration::from_secs(SECS_PER_YEAR),
            UNIX_EPOCH + Duration::from_secs(4_000_000_000),
        ];
        for (i, issued_at) in cases.into_iter().enumerate() {
            let encoded = encode_envelope(issued_at, &[]);
            let (decoded_time, _) =
                decode_envelope(&encoded).expect("encoded envelope must decode");
            assert_eq!(decoded_time, issued_at, "case index {i} mismatch");
        }
    }

    // --- seshcookie-rs.AC6.4: wrong format_version yields BadVersion -------------

    /// seshcookie-rs.AC6.4: a cookie that decrypts but carries
    /// `format_version != 1` is rejected with `BadVersion`. The Phase 3 codec
    /// will map this to `payload = None` and emit a delete-cookie response.
    #[test]
    fn decode_rejects_format_version_2_with_bad_version_ac6_4() {
        let bytes = [2u8, 0, 0, 0, 0, 0, 0, 0, 0];
        assert_eq!(decode_envelope(&bytes), Err(EnvelopeError::BadVersion(2)));
    }

    /// seshcookie-rs.AC6.4: every non-1 single-byte version produces
    /// `BadVersion` carrying the offending byte. We sample 0, 3, and 255 as
    /// representative values around and far above the valid version.
    #[test]
    fn decode_rejects_other_format_versions_with_bad_version_ac6_4() {
        for bad in [0u8, 3, 255] {
            let mut bytes = [0u8; HEADER_LEN];
            bytes[0] = bad;
            assert_eq!(
                decode_envelope(&bytes),
                Err(EnvelopeError::BadVersion(bad)),
                "version {bad} should be rejected"
            );
        }
    }

    // --- Sanity: layer-1 byte layout --------------------------------------------

    /// `encode_envelope` places `FORMAT_VERSION` at byte 0 and produces
    /// exactly `HEADER_LEN` bytes when the payload is empty. This pins the
    /// header position so any future drift would fail the test rather than
    /// silently break wire compatibility.
    #[test]
    fn encode_places_format_version_at_byte_zero() {
        let some_time = UNIX_EPOCH + Duration::from_secs(123_456);
        let bytes = encode_envelope(some_time, &[]);
        assert_eq!(bytes.len(), HEADER_LEN);
        assert_eq!(bytes[0], FORMAT_VERSION);
        assert_eq!(bytes[0], 1);
    }

    /// The 8 issued-at bytes are stored little-endian. We use a value whose
    /// 8-byte big-endian form is `01 02 03 04 05 06 07 08`; little-endian
    /// flips that to `08 07 06 05 04 03 02 01`, which is what the test asserts.
    #[test]
    fn encode_writes_issued_at_little_endian() {
        let secs: u64 = 0x0102_0304_0506_0708;
        let issued_at = UNIX_EPOCH + Duration::from_secs(secs);
        let bytes = encode_envelope(issued_at, &[]);
        assert_eq!(
            &bytes[1..9],
            &[0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]
        );
    }

    /// The payload bytes are appended verbatim after the 9-byte header.
    #[test]
    fn round_trip_passes_payload_through_unchanged() {
        let some_time = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
        let payload = [42u8, 42, 42];
        let encoded = encode_envelope(some_time, &payload);
        let (_, decoded_payload) = decode_envelope(&encoded).expect("encoded envelope must decode");
        assert_eq!(decoded_payload, payload);
    }

    // --- EnvelopeError::TooShort cases ------------------------------------------

    /// `decode_envelope(&[])` returns `TooShort`: zero bytes is below the
    /// header length so neither version nor issued-at can be parsed.
    #[test]
    fn decode_rejects_empty_bytes_with_too_short() {
        assert_eq!(decode_envelope(&[]), Err(EnvelopeError::TooShort));
    }

    /// One byte short of the header length still triggers `TooShort`. We use
    /// `[1, 0, 0, 0, 0, 0, 0, 0]` (8 bytes) — the version byte is correct
    /// but the header is incomplete, so `TooShort` is returned (the length
    /// check runs first).
    #[test]
    fn decode_rejects_eight_bytes_with_too_short() {
        let bytes = [1u8, 0, 0, 0, 0, 0, 0, 0];
        assert_eq!(decode_envelope(&bytes), Err(EnvelopeError::TooShort));
    }

    /// Boundary case: exactly `HEADER_LEN - 1` bytes is `TooShort`; exactly
    /// `HEADER_LEN` is the smallest acceptable input (header only, empty
    /// payload — covered by `round_trip_empty_payload`).
    #[test]
    fn decode_rejects_header_minus_one_bytes_with_too_short() {
        let bytes = vec![1u8; HEADER_LEN - 1];
        assert_eq!(decode_envelope(&bytes), Err(EnvelopeError::TooShort));
    }

    // --- Empty payload edge case ------------------------------------------------

    /// An envelope with an empty payload is valid: encode produces exactly
    /// `HEADER_LEN` bytes, decode round-trips the time and yields an empty
    /// `Vec<u8>` payload.
    #[test]
    fn round_trip_empty_payload() {
        let issued_at = UNIX_EPOCH + Duration::from_secs(1_234_567_890);
        let encoded = encode_envelope(issued_at, &[]);
        assert_eq!(encoded.len(), HEADER_LEN);
        let (decoded_time, decoded_payload) =
            decode_envelope(&encoded).expect("header-only envelope must decode");
        assert_eq!(decoded_time, issued_at);
        assert_eq!(decoded_payload, Vec::<u8>::new());
    }

    // --- Pre-epoch SystemTime round-trip ----------------------------------------

    /// `SystemTime` can be before `UNIX_EPOCH`. The envelope codec must
    /// handle that without panicking and must round-trip the value.
    /// `encode_envelope(UNIX_EPOCH - 1000s, &[])` then decode returns a time
    /// that equals `UNIX_EPOCH - 1000s`. This is an edge case for the
    /// negative-seconds path; production code never produces such times, but
    /// the decoder must be total.
    #[test]
    fn round_trip_pre_epoch_time() {
        let issued_at = UNIX_EPOCH - Duration::from_secs(1000);
        let encoded = encode_envelope(issued_at, &[]);
        let (decoded_time, _) = decode_envelope(&encoded).expect("pre-epoch envelope must decode");
        assert_eq!(decoded_time, issued_at);
    }

    /// The negative-seconds path uses `unsigned_abs()` to convert `i64::MIN`
    /// to `u64` without overflow. We craft the bytes directly because no
    /// portable `SystemTime` API can construct a value that far in the past;
    /// the test confirms `decode_envelope` does not panic on the corner case
    /// and yields a `SystemTime` corresponding to `UNIX_EPOCH -
    /// i64::MIN.unsigned_abs()` seconds.
    #[test]
    fn decode_handles_i64_min_seconds_without_panic() {
        let mut bytes = [0u8; HEADER_LEN];
        bytes[0] = FORMAT_VERSION;
        bytes[1..HEADER_LEN].copy_from_slice(&i64::MIN.to_le_bytes());
        let (decoded_time, _) =
            decode_envelope(&bytes).expect("i64::MIN seconds must not crash decode");
        let expected = UNIX_EPOCH - Duration::from_secs(i64::MIN.unsigned_abs());
        assert_eq!(decoded_time, expected);
    }
}