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(crate) struct SigningKeys {
pub(super) master: [u8; 32],
pub(crate) cookie: [u8; 32],
pub(crate) fingerprint: [u8; 32],
}
impl SigningKeys {
pub(super) 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();
}
}
pub(super) fn signing_sign_bytes(bytes: &[u8], key: &[u8; 32]) -> String {
let mut mac = crate::hmac::new_signer(key.as_ref());
mac.update(bytes);
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub(super) fn signing_decode_cookie(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 = signing_sign_bytes(&id_bytes, key);
let expected_bytes = URL_SAFE_NO_PAD.decode(expected).ok()?;
if mac_bytes.len() != expected_bytes.len() {
return None;
}
if mac_bytes.ct_eq(&expected_bytes).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 signing_sign_bytes_returns_url_safe_base64_of_hmac() {
let key = fixture_key();
let sig = signing_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 = signing_sign_bytes(b"axess:session-id-bytes", &key);
assert_eq!(sig, sig2);
}
#[test]
fn signing_sign_bytes_depends_on_key() {
let sig_a = signing_sign_bytes(b"same-input", &[0xAA; 32]);
let sig_b = signing_sign_bytes(b"same-input", &[0xBB; 32]);
assert_ne!(sig_a, sig_b, "different keys must yield different HMACs");
}
#[test]
fn signing_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 = signing_sign_bytes(id.as_bytes(), &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = signing_decode_cookie(&cookie, &key)
.expect("signed cookie must decode back to a SessionId");
assert_eq!(decoded, id);
}
#[test]
fn signing_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 = signing_sign_bytes(id.as_bytes(), &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = signing_decode_cookie(&cookie, &key);
assert!(
decoded.is_some(),
"16-byte id must decode; `!= → ==` mutant would reject"
);
}
#[test]
fn signing_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 = signing_sign_bytes(&id_bytes, &key);
let cookie = format!("{id_enc}.{mac_enc}");
let decoded = signing_decode_cookie(&cookie, &key);
assert!(
decoded.is_none(),
"15-byte id must reject; `!= → ==` mutant would accept"
);
}
#[test]
fn signing_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 = signing_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 = signing_decode_cookie(&cookie, &key);
assert!(
decoded.is_none(),
"truncated MAC must reject; `!= → ==` mutant on length guard would invert this"
);
}
}