use crate::session::id::SessionId;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::Mac;
use subtle::ConstantTimeEq;
pub(super) fn hkdf_expand_subkey(prk: &[u8; 32], info: &'static [u8]) -> [u8; 32] {
let mut mac = crate::hmac::new_signer(prk);
mac.update(info);
mac.update(&[0x01]);
let bytes = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
out
}
const HKDF_INFO_COOKIE: &[u8] = b"axess.v1.session.cookie.hmac";
const HKDF_INFO_FINGERPRINT: &[u8] = b"axess.v1.session.fingerprint.hmac";
#[derive(Clone)]
pub(super) struct SigningKeys {
master: [u8; 32],
cookie: [u8; 32],
fingerprint: [u8; 32],
}
impl SigningKeys {
fn from_master(master: [u8; 32]) -> Self {
Self {
cookie: hkdf_expand_subkey(&master, HKDF_INFO_COOKIE),
fingerprint: hkdf_expand_subkey(&master, HKDF_INFO_FINGERPRINT),
master,
}
}
}
impl Drop for SigningKeys {
fn drop(&mut self) {
use zeroize::Zeroize;
self.master.zeroize();
self.cookie.zeroize();
self.fingerprint.zeroize();
}
}
#[derive(Clone)]
pub(crate) struct SigningKeyRing {
current: SigningKeys,
previous: Option<SigningKeys>,
}
impl SigningKeyRing {
pub(super) fn from_master(master: [u8; 32]) -> Self {
Self {
current: SigningKeys::from_master(master),
previous: None,
}
}
pub(super) fn set_previous(&mut self, previous_master: [u8; 32]) {
self.previous = Some(SigningKeys::from_master(previous_master));
}
pub(super) fn clear_previous(&mut self) {
self.previous = None;
}
pub(crate) fn has_previous(&self) -> bool {
self.previous.is_some()
}
pub(crate) fn sign_cookie(&self, id: SessionId) -> String {
let id_enc = URL_SAFE_NO_PAD.encode(id.as_bytes());
let mac = sign_bytes(id.as_bytes(), &self.current.cookie);
format!("{id_enc}.{mac}")
}
pub(crate) fn decode_cookie(&self, value: &str) -> Option<VerifiedSessionId> {
if let Some(id) = decode_cookie_with_key(value, &self.current.cookie) {
return Some(VerifiedSessionId {
id,
verified_by_previous: false,
});
}
if let Some(previous) = &self.previous
&& let Some(id) = decode_cookie_with_key(value, &previous.cookie)
{
tracing::debug!(
"session cookie verified with previous (rotated) signing key; \
response will re-sign with current key"
);
return Some(VerifiedSessionId {
id,
verified_by_previous: true,
});
}
None
}
pub(crate) fn compute_binding_fingerprints(&self, material: &[u8]) -> (String, Option<String>) {
let current = sign_bytes(material, &self.current.fingerprint);
let previous = self
.previous
.as_ref()
.map(|k| sign_bytes(material, &k.fingerprint));
(current, previous)
}
pub(crate) fn derive_subkey(&self, info: &'static [u8]) -> [u8; 32] {
hkdf_expand_subkey(&self.current.master, info)
}
}
pub(super) fn hmac_bytes(bytes: &[u8], key: &[u8; 32]) -> [u8; 32] {
let mut mac = crate::hmac::new_signer(key);
mac.update(bytes);
mac.finalize().into_bytes().into()
}
pub(super) fn sign_bytes(bytes: &[u8], key: &[u8; 32]) -> String {
URL_SAFE_NO_PAD.encode(hmac_bytes(bytes, key))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct VerifiedSessionId {
pub(crate) id: SessionId,
pub(crate) verified_by_previous: bool,
}
pub(super) fn decode_cookie_with_key(value: &str, key: &[u8; 32]) -> Option<SessionId> {
let (id_enc, mac_enc) = value.split_once('.')?;
let id_bytes = URL_SAFE_NO_PAD.decode(id_enc).ok()?;
if id_bytes.len() != 16 {
return None;
}
let mac_bytes = URL_SAFE_NO_PAD.decode(mac_enc).ok()?;
let expected = hmac_bytes(&id_bytes, key);
if mac_bytes.len() != expected.len() {
return None;
}
if mac_bytes.ct_eq(&expected).into() {
let arr: [u8; 16] = id_bytes.try_into().ok()?;
Some(SessionId::from_bytes(arr))
} else {
None
}
}
#[cfg(test)]
mod subkey_derivation_tests {
use super::*;
use crate::session::layer::SessionLayer;
use crate::session::store::MemorySessionStore;
#[test]
fn different_masters_yield_different_subkeys() {
let layer_a = SessionLayer::new(MemorySessionStore::new(), [0xAA; 32]);
let layer_b = SessionLayer::new(MemorySessionStore::new(), [0xBB; 32]);
let info = b"axess.test.v1";
let key_a = layer_a.derive_subkey(info);
let key_b = layer_b.derive_subkey(info);
assert_ne!(
*key_a, *key_b,
"derive_subkey must depend on the master key; \
two layers with different masters produced the same sub-key"
);
assert_ne!(
*key_a, [0u8; 32],
"derive_subkey returned the [0;32] mutant value"
);
assert_ne!(
*key_a, [1u8; 32],
"derive_subkey returned the [1;32] mutant value"
);
assert_ne!(*key_b, [0u8; 32]);
assert_ne!(*key_b, [1u8; 32]);
}
#[test]
fn different_info_labels_yield_different_subkeys() {
let layer = SessionLayer::new(MemorySessionStore::new(), [0x42; 32]);
let key_cookie = layer.derive_subkey(b"axess.v1.session.cookie.hmac");
let key_csrf = layer.derive_subkey(b"axess.v1.csrf");
let key_push = layer.derive_subkey(b"axess.v1.push");
assert_ne!(key_cookie, key_csrf);
assert_ne!(key_cookie, key_push);
assert_ne!(key_csrf, key_push);
}
#[test]
fn subkey_is_deterministic_under_same_inputs() {
let layer = SessionLayer::new(MemorySessionStore::new(), [0x77; 32]);
let info = b"axess.v1.session.cookie.hmac";
let k1 = layer.derive_subkey(info);
let k2 = layer.derive_subkey(info);
assert_eq!(
k1, k2,
"derive_subkey is not deterministic; \
two calls with identical inputs produced different keys"
);
}
#[test]
fn signing_keys_cookie_subkey_is_non_constant() {
let keys_a = SigningKeys::from_master([0xAA; 32]);
let keys_b = SigningKeys::from_master([0xBB; 32]);
assert_ne!(keys_a.cookie, [0u8; 32]);
assert_ne!(keys_a.cookie, [1u8; 32]);
assert_ne!(keys_a.fingerprint, [0u8; 32]);
assert_ne!(keys_a.fingerprint, [1u8; 32]);
assert_ne!(
keys_a.cookie, keys_b.cookie,
"two different masters produced the same cookie sub-key"
);
assert_ne!(
keys_a.fingerprint, keys_b.fingerprint,
"two different masters produced the same fingerprint sub-key"
);
assert_ne!(
keys_a.cookie, keys_a.fingerprint,
"cookie and fingerprint sub-keys collapsed to the same value"
);
}
}
#[cfg(test)]
mod signing_helpers_tests {
use super::*;
use axess_rng::SystemRng;
fn fixture_key() -> [u8; 32] {
[0xA5; 32]
}
#[test]
fn sign_bytes_returns_url_safe_base64_of_hmac() {
let key = fixture_key();
let sig = sign_bytes(b"axess:session-id-bytes", &key);
assert!(!sig.is_empty(), "HMAC encoding must not be empty");
assert_eq!(
sig.len(),
43,
"URL_SAFE_NO_PAD-encoded SHA256 must be 43 chars, got {sig:?}"
);
let sig2 = sign_bytes(b"axess:session-id-bytes", &key);
assert_eq!(sig, sig2);
}
#[test]
fn sign_bytes_depends_on_key() {
let sig_a = sign_bytes(b"same-input", &[0xAA; 32]);
let sig_b = sign_bytes(b"same-input", &[0xBB; 32]);
assert_ne!(sig_a, sig_b, "different keys must yield different HMACs");
}
#[test]
fn decode_cookie_round_trips_a_signed_cookie() {
let key = fixture_key();
let id = SessionId::new(&SystemRng);
let id_enc = URL_SAFE_NO_PAD.encode(id.as_bytes());
let mac_enc = sign_bytes(id.as_bytes(), &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = decode_cookie_with_key(&cookie, &key)
.expect("signed cookie must decode back to a SessionId");
assert_eq!(decoded, id);
}
#[test]
fn decode_cookie_accepts_16_byte_id() {
let key = fixture_key();
let id = SessionId::from_bytes([0xC3; 16]);
let id_enc = URL_SAFE_NO_PAD.encode(id.as_bytes());
let mac_enc = sign_bytes(id.as_bytes(), &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = decode_cookie_with_key(&cookie, &key);
assert!(
decoded.is_some(),
"16-byte id must decode; `!= → ==` mutant would reject"
);
}
#[test]
fn decode_cookie_rejects_wrong_length_id() {
let key = fixture_key();
let id_bytes = [0xC3; 15];
let id_enc = URL_SAFE_NO_PAD.encode(id_bytes);
let mac_enc = sign_bytes(&id_bytes, &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = decode_cookie_with_key(&cookie, &key);
assert!(
decoded.is_none(),
"15-byte id must reject; `!= → ==` mutant would accept"
);
}
#[test]
fn decode_cookie_rejects_truncated_mac() {
let key = fixture_key();
let id = SessionId::from_bytes([0xD4; 16]);
let id_enc = URL_SAFE_NO_PAD.encode(id.as_bytes());
let full_mac_enc = sign_bytes(id.as_bytes(), &key);
let full_mac_bytes = URL_SAFE_NO_PAD.decode(&full_mac_enc).unwrap();
let truncated_bytes = &full_mac_bytes[..full_mac_bytes.len() / 2];
let truncated_mac_enc = URL_SAFE_NO_PAD.encode(truncated_bytes);
let cookie = format!("{id_enc}.{truncated_mac_enc}");
let decoded = decode_cookie_with_key(&cookie, &key);
assert!(
decoded.is_none(),
"truncated MAC must reject; `!= → ==` mutant on length guard would invert this"
);
}
}
#[cfg(test)]
mod rotation_tests {
use super::*;
use axess_rng::SystemRng;
fn cookie_signed_by(master: [u8; 32], id: SessionId) -> String {
let keys = SigningKeys::from_master(master);
let id_enc = URL_SAFE_NO_PAD.encode(id.as_bytes());
let mac = sign_bytes(id.as_bytes(), &keys.cookie);
format!("{id_enc}.{mac}")
}
#[test]
fn no_rotation_verifies_current_and_flags_no_fallback() {
let master = [0xAA; 32];
let ring = SigningKeyRing::from_master(master);
let id = SessionId::new(&SystemRng);
let cookie = cookie_signed_by(master, id);
let out = ring
.decode_cookie(&cookie)
.expect("current-key cookie must verify");
assert_eq!(out.id, id);
assert!(
!out.verified_by_previous,
"no rotation window → must not flag previous-key fallback"
);
}
#[test]
fn rotation_active_current_cookie_flags_no_fallback() {
let current = [0xBB; 32];
let previous = [0xAA; 32];
let ring = SigningKeyRing {
current: SigningKeys::from_master(current),
previous: Some(SigningKeys::from_master(previous)),
};
let id = SessionId::new(&SystemRng);
let cookie = cookie_signed_by(current, id);
let out = ring
.decode_cookie(&cookie)
.expect("current-key cookie must verify under rotation");
assert_eq!(out.id, id);
assert!(
!out.verified_by_previous,
"current-key match must not flag fallback"
);
}
#[test]
fn rotation_active_previous_cookie_falls_back_and_flags() {
let current = [0xBB; 32];
let previous = [0xAA; 32];
let ring = SigningKeyRing {
current: SigningKeys::from_master(current),
previous: Some(SigningKeys::from_master(previous)),
};
let id = SessionId::new(&SystemRng);
let cookie = cookie_signed_by(previous, id);
let out = ring
.decode_cookie(&cookie)
.expect("previous-key cookie must verify under rotation");
assert_eq!(out.id, id);
assert!(
out.verified_by_previous,
"previous-key match MUST flag fallback so caller re-signs under current"
);
}
#[test]
fn rotation_active_unknown_cookie_rejected() {
let current = [0xBB; 32];
let previous = [0xAA; 32];
let unknown = [0xCC; 32];
let ring = SigningKeyRing {
current: SigningKeys::from_master(current),
previous: Some(SigningKeys::from_master(previous)),
};
let id = SessionId::new(&SystemRng);
let cookie = cookie_signed_by(unknown, id);
assert!(
ring.decode_cookie(&cookie).is_none(),
"cookie signed under a third unknown key must reject; no forgery via rotation slot"
);
}
#[test]
fn chained_rotation_drops_the_original_key() {
let k0 = [0xAA; 32]; let k1 = [0xBB; 32]; let k2 = [0xCC; 32];
let ring_after_two_rotations = SigningKeyRing {
current: SigningKeys::from_master(k2),
previous: Some(SigningKeys::from_master(k1)),
};
let id = SessionId::new(&SystemRng);
let cookie_from_k0 = cookie_signed_by(k0, id);
assert!(
ring_after_two_rotations
.decode_cookie(&cookie_from_k0)
.is_none(),
"cookie signed under a key rotated out two windows ago must not verify"
);
let cookie_from_k1 = cookie_signed_by(k1, id);
let out = ring_after_two_rotations
.decode_cookie(&cookie_from_k1)
.expect("k1 is now the previous slot and must still verify");
assert!(out.verified_by_previous);
}
#[test]
fn ring_has_previous_reflects_state() {
let no_rotation = SigningKeyRing::from_master([0xAA; 32]);
assert!(!no_rotation.has_previous());
let rotation_active = SigningKeyRing {
current: SigningKeys::from_master([0xBB; 32]),
previous: Some(SigningKeys::from_master([0xAA; 32])),
};
assert!(rotation_active.has_previous());
}
#[test]
fn ring_clear_previous_ends_rotation_window() {
let old = [0xAA; 32];
let new = [0xBB; 32];
let id = SessionId::new(&SystemRng);
let mut ring = SigningKeyRing::from_master(new);
ring.set_previous(old);
assert!(ring.has_previous());
let cookie_from_old = cookie_signed_by(old, id);
ring.decode_cookie(&cookie_from_old)
.expect("previous key must verify while rotation slot is populated");
ring.clear_previous();
assert!(!ring.has_previous());
assert!(
ring.decode_cookie(&cookie_from_old).is_none(),
"cookies signed under the retired previous key must no longer verify"
);
}
}