1use std::fmt;
2
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use thiserror::Error;
6use zeroize::Zeroizing;
7
8use crate::{FixedWindowPolicy, PolicyId, ScopeId};
9
10const KEY_DOMAIN: &[u8] = b"runlimit/subject-key/v1\0";
11
12type HmacSha256 = Hmac<Sha256>;
13
14#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct SubjectKey([u8; 32]);
21
22impl SubjectKey {
23 pub const fn from_digest(digest: [u8; 32]) -> Self {
27 Self(digest)
28 }
29
30 pub const fn as_bytes(&self) -> &[u8; 32] {
32 &self.0
33 }
34
35 pub const fn into_bytes(self) -> [u8; 32] {
37 self.0
38 }
39}
40
41impl fmt::Debug for SubjectKey {
42 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43 formatter.write_str("SubjectKey([REDACTED])")
44 }
45}
46
47pub struct KeyHasher {
56 secret: Zeroizing<Vec<u8>>,
57}
58
59impl KeyHasher {
60 pub const MINIMUM_SECRET_LENGTH: usize = 32;
62
63 pub fn new(secret: impl AsRef<[u8]>) -> Result<Self, KeyHasherError> {
70 let secret = secret.as_ref();
71 if secret.len() < Self::MINIMUM_SECRET_LENGTH {
72 return Err(KeyHasherError::SecretTooShort {
73 actual: secret.len(),
74 minimum: Self::MINIMUM_SECRET_LENGTH,
75 });
76 }
77
78 Ok(Self {
79 secret: Zeroizing::new(secret.to_vec()),
80 })
81 }
82
83 pub fn hash(
88 &self,
89 policy_id: &PolicyId,
90 scope_id: &ScopeId,
91 subject: impl AsRef<[u8]>,
92 ) -> SubjectKey {
93 let Ok(mut mac) = HmacSha256::new_from_slice(&self.secret) else {
97 unreachable!("HMAC-SHA-256 accepts keys of every length");
98 };
99 mac.update(KEY_DOMAIN);
100 mac.update(policy_id.as_str().as_bytes());
101 mac.update(&[0]);
102 mac.update(scope_id.as_str().as_bytes());
103 mac.update(&[0]);
104 mac.update(subject.as_ref());
105 SubjectKey::from_digest(mac.finalize().into_bytes().into())
106 }
107
108 pub fn hash_for(&self, policy: &FixedWindowPolicy, subject: impl AsRef<[u8]>) -> SubjectKey {
110 self.hash(policy.id(), policy.scope(), subject)
111 }
112}
113
114impl fmt::Debug for KeyHasher {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 formatter.write_str("KeyHasher([REDACTED])")
117 }
118}
119
120#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
122pub enum KeyHasherError {
123 #[error("key-hashing secret is {actual} bytes; at least {minimum} bytes are required")]
125 SecretTooShort {
126 actual: usize,
128 minimum: usize,
130 },
131}
132
133#[cfg(test)]
134mod tests {
135 use std::time::Duration;
136
137 use super::{KeyHasher, KeyHasherError, SubjectKey};
138 use crate::{FixedWindowPolicy, PolicyId, ScopeId};
139
140 fn hasher() -> KeyHasher {
141 KeyHasher::new([0x42; 32]).unwrap()
142 }
143
144 fn policy(id: &str, scope: &str) -> FixedWindowPolicy {
145 FixedWindowPolicy::new(
146 PolicyId::new(id).unwrap(),
147 ScopeId::new(scope).unwrap(),
148 8,
149 Duration::from_secs(60),
150 )
151 .unwrap()
152 }
153
154 #[test]
155 fn rejects_short_secrets() {
156 assert_eq!(
157 KeyHasher::new([0; 31]).unwrap_err(),
158 KeyHasherError::SecretTooShort {
159 actual: 31,
160 minimum: 32,
161 }
162 );
163 }
164
165 #[test]
166 fn accepts_secrets_longer_than_the_minimum() {
167 assert!(KeyHasher::new([0; 64]).is_ok());
168 }
169
170 #[test]
171 fn hashing_is_deterministic_within_a_namespace() {
172 let policy = policy("auth.login", "identity");
173 let first = hasher().hash_for(&policy, b"user@example.test");
174 let second = hasher().hash_for(&policy, b"user@example.test");
175
176 assert_eq!(first, second);
177 }
178
179 #[test]
180 fn policy_and_scope_domain_separate_subjects() {
181 let hasher = hasher();
182 let login_identity =
183 hasher.hash_for(&policy("auth.login", "identity"), b"user@example.test");
184 let signup_identity =
185 hasher.hash_for(&policy("auth.signup", "identity"), b"user@example.test");
186 let login_client = hasher.hash_for(&policy("auth.login", "client"), b"user@example.test");
187
188 assert_ne!(login_identity, signup_identity);
189 assert_ne!(login_identity, login_client);
190 }
191
192 #[test]
193 fn subjects_and_secrets_change_the_digest() {
194 let policy = policy("auth.login", "identity");
195 let first = hasher().hash_for(&policy, b"first");
196 let second = hasher().hash_for(&policy, b"second");
197 let other_secret = KeyHasher::new([0x24; 32])
198 .unwrap()
199 .hash_for(&policy, b"first");
200
201 assert_ne!(first, second);
202 assert_ne!(first, other_secret);
203 }
204
205 #[test]
206 fn subject_key_debug_output_is_redacted() {
207 let key = SubjectKey::from_digest([0xab; 32]);
208 let output = format!("{key:?}");
209
210 assert_eq!(output, "SubjectKey([REDACTED])");
211 assert!(!output.contains("ab"));
212 assert_eq!(key.as_bytes(), &[0xab; 32]);
213 assert_eq!(key.into_bytes(), [0xab; 32]);
214 }
215
216 #[test]
217 fn hasher_debug_output_is_redacted() {
218 assert_eq!(format!("{:?}", hasher()), "KeyHasher([REDACTED])");
219 }
220}