seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
//! User secret validation and HKDF-SHA256 derivation parameters.
//!
//! pattern: Functional Core
//!
//! [`SessionKeys`] holds an ordered list of user-supplied secrets: one primary plus
//! zero or more fallbacks. Secrets are validated for length at construction time;
//! derivation into the AEAD-key form happens at [`crate::SessionLayer::new`] so the
//! request path never runs HKDF.
//!
//! This module is part of the Functional Core: it performs pure input validation on
//! caller-supplied keying material and exposes constants used by the AEAD layer.
//! No I/O, randomness, or wall-clock reads occur here.

use crate::error::BuildError;

/// Minimum acceptable length, in bytes, of any caller-supplied secret. Short secrets
/// are rejected at construction time so that low-entropy material cannot reach the HKDF
/// extract step.
pub(crate) const MIN_KEY_BYTES: usize = 16;

/// Length, in bytes, of the AEAD key derived from any IKM. Equals the
/// ChaCha20-Poly1305 key length expected by `ring::aead::CHACHA20_POLY1305`.
pub(crate) const DERIVED_KEY_LEN: usize = 32;

/// Fixed HKDF salt context. The AEAD layer hashes these bytes to SHA-256 to obtain a
/// 32-byte salt, providing domain separation between this crate and any other user of
/// HKDF-SHA256 with the same input keying material.
pub(crate) const HKDF_SALT_CONTEXT: &[u8] = b"seshcookie-rs-v1-salt";

/// HKDF info parameter. Binds the derived key to this crate's wire-format version and
/// the chosen AEAD algorithm so that material derived for one purpose cannot be
/// substituted for another.
pub(crate) const HKDF_INFO: &[u8] = b"seshcookie-rs v1 ChaCha20-Poly1305 session";

/// Ordered list of input keying material (IKM) for AEAD derivation: a primary secret
/// plus zero or more fallbacks. The primary is always tried first when decrypting; new
/// cookies are always sealed under the primary. Fallbacks let operators rotate secrets
/// without invalidating sessions issued under the previous primary.
///
/// Construction always validates each secret against the 16-byte minimum. Once
/// constructed, a `SessionKeys` value cannot hold an invalid secret, so the AEAD layer
/// can derive without re-checking.
///
/// Cloning is cheap relative to crypto cost: the underlying buffers are byte vectors,
/// and a typical deployment carries 1-3 keys.
#[derive(Clone)]
pub struct SessionKeys {
    primary: Vec<u8>,
    fallbacks: Vec<Vec<u8>>,
}

impl SessionKeys {
    /// Construct a new key list from a single primary secret.
    ///
    /// The secret must be at least 16 bytes of uniformly-random keying material. This
    /// crate does not apply a password-hashing KDF — supplying a low-entropy string as
    /// the secret makes offline attacks trivial.
    ///
    /// # Errors
    ///
    /// Returns [`BuildError::EmptyKey`] if `primary` is empty, or
    /// [`BuildError::ShortKey`] if its length is below 16 bytes.
    ///
    /// # Example
    ///
    /// ```
    /// use seshcookie::SessionKeys;
    ///
    /// let keys = SessionKeys::new(b"0123456789abcdef0123456789abcdef")?;
    /// # let _ = keys;
    /// # Ok::<(), seshcookie::BuildError>(())
    /// ```
    pub fn new(primary: &[u8]) -> Result<Self, BuildError> {
        validate_ikm(primary)?;
        Ok(Self {
            primary: primary.to_vec(),
            fallbacks: Vec::new(),
        })
    }

    /// Append a single fallback secret, returning the updated key list.
    ///
    /// Fallbacks are tried after the primary when decrypting incoming cookies. Same
    /// length validation as [`SessionKeys::new`].
    ///
    /// # Errors
    ///
    /// Returns [`BuildError::EmptyKey`] or [`BuildError::ShortKey`] on invalid input.
    /// Because the method consumes `self`, the caller's prior value is dropped on
    /// error and no half-appended state is observable.
    ///
    /// # Example
    ///
    /// ```
    /// use seshcookie::SessionKeys;
    ///
    /// let keys = SessionKeys::new(b"new-key-0123456789abcdef0123456789")?
    ///     .with_fallback(b"old-key-0123456789abcdef0123456789")?;
    /// # let _ = keys;
    /// # Ok::<(), seshcookie::BuildError>(())
    /// ```
    pub fn with_fallback(mut self, fallback: &[u8]) -> Result<Self, BuildError> {
        validate_ikm(fallback)?;
        self.fallbacks.push(fallback.to_vec());
        Ok(self)
    }

    /// Append zero or more fallback secrets in iterator order.
    ///
    /// All fallbacks are validated *before* any are appended, so a single invalid
    /// entry causes the whole call to fail without partial mutation. Accepts any
    /// iterator whose items are convertible to byte slices (e.g. `&[u8]`, `Vec<u8>`,
    /// `[u8; N]`).
    ///
    /// # Errors
    ///
    /// Returns [`BuildError::EmptyKey`] or [`BuildError::ShortKey`] on the first
    /// invalid entry encountered during validation.
    ///
    /// # Example
    ///
    /// ```
    /// use seshcookie::SessionKeys;
    ///
    /// let keys = SessionKeys::new(b"v3-0123456789abcdef0123456789abc")?
    ///     .with_fallbacks([
    ///         &b"v2-0123456789abcdef0123456789abc"[..],
    ///         &b"v1-0123456789abcdef0123456789abc"[..],
    ///     ])?;
    /// # let _ = keys;
    /// # Ok::<(), seshcookie::BuildError>(())
    /// ```
    pub fn with_fallbacks<I, B>(mut self, iter: I) -> Result<Self, BuildError>
    where
        I: IntoIterator<Item = B>,
        B: AsRef<[u8]>,
    {
        let collected: Vec<Vec<u8>> = iter.into_iter().map(|b| b.as_ref().to_vec()).collect();
        for ikm in &collected {
            validate_ikm(ikm)?;
        }
        self.fallbacks.extend(collected);
        Ok(self)
    }

    /// Total number of keys held: 1 (primary) + the number of fallbacks.
    #[allow(dead_code)] // Consumed by tests in this module and by Phase 3 wiring.
    pub(crate) fn total_keys(&self) -> usize {
        1 + self.fallbacks.len()
    }

    /// Borrowed iterator over IKM bytes in trial order: primary first, then each
    /// fallback in insertion order. Used by the AEAD layer to derive `LessSafeKey`
    /// instances and by `try_decrypt` to walk keys in priority order.
    pub(crate) fn ikm_in_order(&self) -> impl Iterator<Item = &[u8]> {
        std::iter::once(self.primary.as_slice()).chain(self.fallbacks.iter().map(Vec::as_slice))
    }
}

/// Validate that `bytes` is non-empty and at least [`MIN_KEY_BYTES`] long.
///
/// Returns [`BuildError::EmptyKey`] for the empty case (more specific than
/// `ShortKey { len: 0 }`) and [`BuildError::ShortKey`] otherwise. The two-tier
/// classification lets callers tell "the caller forgot to populate the secret
/// entirely" from "the caller supplied a too-short value."
fn validate_ikm(bytes: &[u8]) -> Result<(), BuildError> {
    if bytes.is_empty() {
        return Err(BuildError::EmptyKey);
    }
    if bytes.len() < MIN_KEY_BYTES {
        return Err(BuildError::ShortKey { len: bytes.len() });
    }
    Ok(())
}

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

    // --- seshcookie-rs.AC7.3: empty secret rejection -------------------------------

    /// seshcookie-rs.AC7.3: an empty primary secret is rejected with `EmptyKey`.
    #[test]
    fn new_rejects_empty_primary_with_empty_key_ac7_3() {
        let result = SessionKeys::new(&[]);
        assert_eq!(result.err(), Some(BuildError::EmptyKey));
    }

    /// seshcookie-rs.AC7.3: an empty fallback is rejected with `EmptyKey`.
    #[test]
    fn with_fallback_rejects_empty_with_empty_key_ac7_3() {
        let keys = SessionKeys::new(&[0u8; 16]).expect("16-byte primary is valid");
        let result = keys.with_fallback(&[]);
        assert_eq!(result.err(), Some(BuildError::EmptyKey));
    }

    /// seshcookie-rs.AC7.3: an empty entry inside `with_fallbacks` is rejected with
    /// `EmptyKey`.
    #[test]
    fn with_fallbacks_rejects_empty_entry_with_empty_key_ac7_3() {
        let keys = SessionKeys::new(&[0u8; 16]).expect("16-byte primary is valid");
        let result = keys.with_fallbacks([&[0u8; 16][..], &[][..]]);
        assert_eq!(result.err(), Some(BuildError::EmptyKey));
    }

    // --- seshcookie-rs.AC7.4: short secret rejection -------------------------------

    /// seshcookie-rs.AC7.4: a 15-byte primary is rejected with `ShortKey { len: 15 }`.
    #[test]
    fn new_rejects_15_byte_primary_with_short_key_ac7_4() {
        let result = SessionKeys::new(&[0u8; 15]);
        assert_eq!(result.err(), Some(BuildError::ShortKey { len: 15 }));
    }

    /// seshcookie-rs.AC7.4: a 7-byte fallback is rejected with `ShortKey { len: 7 }`.
    #[test]
    fn with_fallback_rejects_7_byte_with_short_key_ac7_4() {
        let keys = SessionKeys::new(&[0u8; 16]).expect("16-byte primary is valid");
        let result = keys.with_fallback(&[0u8; 7]);
        assert_eq!(result.err(), Some(BuildError::ShortKey { len: 7 }));
    }

    /// seshcookie-rs.AC7.4: when one entry of `with_fallbacks` is short, the whole
    /// call returns the relevant `ShortKey` error and no partial mutation is
    /// observable.
    ///
    /// Because `with_fallbacks` consumes `self`, on `Err` no `SessionKeys` value is
    /// returned, so a half-appended state cannot be witnessed by the caller.
    #[test]
    fn with_fallbacks_reports_short_entry_ac7_4() {
        let keys = SessionKeys::new(&[0u8; 16]).expect("16-byte primary is valid");
        let result = keys.with_fallbacks([&[0u8; 16][..], &[0u8; 5][..]]);
        assert_eq!(result.err(), Some(BuildError::ShortKey { len: 5 }));
    }

    // --- seshcookie-rs.AC7.5: minimum-length acceptance ----------------------------

    /// seshcookie-rs.AC7.5: a 16-byte primary is accepted; total_keys == 1.
    #[test]
    fn new_accepts_16_byte_primary_ac7_5() {
        let keys = SessionKeys::new(&[0u8; 16]).expect("16-byte primary is valid");
        assert_eq!(keys.total_keys(), 1);
    }

    /// seshcookie-rs.AC7.5: a 16-byte fallback is accepted; the resulting list yields
    /// primary then fallback in order.
    #[test]
    fn with_fallback_accepts_16_byte_and_orders_correctly_ac7_5() {
        let primary = [0xAAu8; 16];
        let fallback = [0xBBu8; 16];
        let keys = SessionKeys::new(&primary)
            .expect("primary is valid")
            .with_fallback(&fallback)
            .expect("fallback is valid");

        assert_eq!(keys.total_keys(), 2);

        let collected: Vec<&[u8]> = keys.ikm_in_order().collect();
        assert_eq!(collected.len(), 2);
        assert_eq!(collected[0], &primary[..]);
        assert_eq!(collected[1], &fallback[..]);
    }

    // --- Sanity tests ---------------------------------------------------------------

    /// `with_fallbacks` accepts iterators yielding `&[u8]`.
    #[test]
    fn with_fallbacks_accepts_slice_refs() {
        let primary = [0u8; 16];
        let f1 = [1u8; 16];
        let f2 = [2u8; 16];
        let keys = SessionKeys::new(&primary)
            .expect("primary is valid")
            .with_fallbacks([&f1[..], &f2[..]])
            .expect("both fallbacks are valid");
        assert_eq!(keys.total_keys(), 3);
    }

    /// `with_fallbacks` accepts iterators yielding owned `Vec<u8>`.
    #[test]
    fn with_fallbacks_accepts_owned_vecs() {
        let primary = [0u8; 16];
        let f1: Vec<u8> = vec![1u8; 16];
        let f2: Vec<u8> = vec![2u8; 16];
        let keys = SessionKeys::new(&primary)
            .expect("primary is valid")
            .with_fallbacks(vec![f1, f2])
            .expect("both fallbacks are valid");
        assert_eq!(keys.total_keys(), 3);
    }

    /// `with_fallbacks` accepts iterators yielding fixed-size byte arrays via
    /// `AsRef<[u8]>`.
    #[test]
    fn with_fallbacks_accepts_byte_arrays() {
        let primary = [0u8; 16];
        let arrays: [[u8; 16]; 2] = [[3u8; 16], [4u8; 16]];
        let keys = SessionKeys::new(&primary)
            .expect("primary is valid")
            .with_fallbacks(arrays)
            .expect("both fallbacks are valid");
        assert_eq!(keys.total_keys(), 3);
    }

    /// `total_keys` always equals 1 plus the number of appended fallbacks.
    #[test]
    fn total_keys_tracks_fallback_count() {
        let primary = [0u8; 16];
        let mut keys = SessionKeys::new(&primary).expect("valid");
        assert_eq!(keys.total_keys(), 1);
        for n in 1..=5 {
            keys = keys
                .with_fallback(&[0u8; 16])
                .expect("16-byte fallback is valid");
            assert_eq!(keys.total_keys(), 1 + n);
        }
    }

    /// `ikm_in_order` yields the primary first, then fallbacks in insertion order.
    #[test]
    fn ikm_in_order_preserves_insertion_order() {
        let primary = [0xAAu8; 16];
        let f1 = [0xBBu8; 16];
        let f2 = [0xCCu8; 16];
        let f3 = [0xDDu8; 16];
        let keys = SessionKeys::new(&primary)
            .expect("primary is valid")
            .with_fallback(&f1)
            .expect("f1 is valid")
            .with_fallbacks([&f2[..], &f3[..]])
            .expect("f2, f3 are valid");

        let collected: Vec<&[u8]> = keys.ikm_in_order().collect();
        assert_eq!(collected, vec![&primary[..], &f1[..], &f2[..], &f3[..]]);
    }

    /// Validation boundary: 17 bytes (one above the minimum) is accepted.
    #[test]
    fn new_accepts_17_byte_primary() {
        assert!(SessionKeys::new(&[0u8; 17]).is_ok());
    }

    /// Validation boundary: 1 byte is rejected with `ShortKey { len: 1 }`, not
    /// `EmptyKey` (the empty case is more specific).
    #[test]
    fn new_rejects_1_byte_with_short_key_not_empty() {
        let result = SessionKeys::new(&[0u8; 1]);
        assert_eq!(result.err(), Some(BuildError::ShortKey { len: 1 }));
    }

    /// `BuildError` variants format the way callers see in their logs.
    #[test]
    fn build_error_display_messages() {
        assert_eq!(
            BuildError::EmptyKey.to_string(),
            "secret key must not be empty"
        );
        assert_eq!(
            BuildError::ShortKey { len: 7 }.to_string(),
            "secret must be at least 16 bytes, got 7"
        );
    }
}