use std::fmt;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use thiserror::Error;
use zeroize::Zeroizing;
use crate::{FixedWindowPolicy, PolicyId, ScopeId};
const KEY_DOMAIN: &[u8] = b"runlimit/subject-key/v1\0";
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SubjectKey([u8; 32]);
impl SubjectKey {
pub const fn from_digest(digest: [u8; 32]) -> Self {
Self(digest)
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub const fn into_bytes(self) -> [u8; 32] {
self.0
}
}
impl fmt::Debug for SubjectKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SubjectKey([REDACTED])")
}
}
pub struct KeyHasher {
secret: Zeroizing<Vec<u8>>,
}
impl KeyHasher {
pub const MINIMUM_SECRET_LENGTH: usize = 32;
pub fn new(secret: impl AsRef<[u8]>) -> Result<Self, KeyHasherError> {
let secret = secret.as_ref();
if secret.len() < Self::MINIMUM_SECRET_LENGTH {
return Err(KeyHasherError::SecretTooShort {
actual: secret.len(),
minimum: Self::MINIMUM_SECRET_LENGTH,
});
}
Ok(Self {
secret: Zeroizing::new(secret.to_vec()),
})
}
pub fn hash(
&self,
policy_id: &PolicyId,
scope_id: &ScopeId,
subject: impl AsRef<[u8]>,
) -> SubjectKey {
let Ok(mut mac) = HmacSha256::new_from_slice(&self.secret) else {
unreachable!("HMAC-SHA-256 accepts keys of every length");
};
mac.update(KEY_DOMAIN);
mac.update(policy_id.as_str().as_bytes());
mac.update(&[0]);
mac.update(scope_id.as_str().as_bytes());
mac.update(&[0]);
mac.update(subject.as_ref());
SubjectKey::from_digest(mac.finalize().into_bytes().into())
}
pub fn hash_for(&self, policy: &FixedWindowPolicy, subject: impl AsRef<[u8]>) -> SubjectKey {
self.hash(policy.id(), policy.scope(), subject)
}
}
impl fmt::Debug for KeyHasher {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("KeyHasher([REDACTED])")
}
}
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum KeyHasherError {
#[error("key-hashing secret is {actual} bytes; at least {minimum} bytes are required")]
SecretTooShort {
actual: usize,
minimum: usize,
},
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{KeyHasher, KeyHasherError, SubjectKey};
use crate::{FixedWindowPolicy, PolicyId, ScopeId};
fn hasher() -> KeyHasher {
KeyHasher::new([0x42; 32]).unwrap()
}
fn policy(id: &str, scope: &str) -> FixedWindowPolicy {
FixedWindowPolicy::new(
PolicyId::new(id).unwrap(),
ScopeId::new(scope).unwrap(),
8,
Duration::from_secs(60),
)
.unwrap()
}
#[test]
fn rejects_short_secrets() {
assert_eq!(
KeyHasher::new([0; 31]).unwrap_err(),
KeyHasherError::SecretTooShort {
actual: 31,
minimum: 32,
}
);
}
#[test]
fn accepts_secrets_longer_than_the_minimum() {
assert!(KeyHasher::new([0; 64]).is_ok());
}
#[test]
fn hashing_is_deterministic_within_a_namespace() {
let policy = policy("auth.login", "identity");
let first = hasher().hash_for(&policy, b"user@example.test");
let second = hasher().hash_for(&policy, b"user@example.test");
assert_eq!(first, second);
}
#[test]
fn policy_and_scope_domain_separate_subjects() {
let hasher = hasher();
let login_identity =
hasher.hash_for(&policy("auth.login", "identity"), b"user@example.test");
let signup_identity =
hasher.hash_for(&policy("auth.signup", "identity"), b"user@example.test");
let login_client = hasher.hash_for(&policy("auth.login", "client"), b"user@example.test");
assert_ne!(login_identity, signup_identity);
assert_ne!(login_identity, login_client);
}
#[test]
fn subjects_and_secrets_change_the_digest() {
let policy = policy("auth.login", "identity");
let first = hasher().hash_for(&policy, b"first");
let second = hasher().hash_for(&policy, b"second");
let other_secret = KeyHasher::new([0x24; 32])
.unwrap()
.hash_for(&policy, b"first");
assert_ne!(first, second);
assert_ne!(first, other_secret);
}
#[test]
fn subject_key_debug_output_is_redacted() {
let key = SubjectKey::from_digest([0xab; 32]);
let output = format!("{key:?}");
assert_eq!(output, "SubjectKey([REDACTED])");
assert!(!output.contains("ab"));
assert_eq!(key.as_bytes(), &[0xab; 32]);
assert_eq!(key.into_bytes(), [0xab; 32]);
}
#[test]
fn hasher_debug_output_is_redacted() {
assert_eq!(format!("{:?}", hasher()), "KeyHasher([REDACTED])");
}
}