1use std::fmt;
2
3use hmac::{Hmac, KeyInit, Mac};
4use sha2::Sha256;
5use thiserror::Error;
6
7use crate::{PolicyId, RateLimitPolicy, ScopeId};
8
9const KEY_DOMAIN: &[u8] = b"runlimit/subject-key/v1\0";
10
11type HmacSha256 = Hmac<Sha256>;
12
13#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct SubjectKey([u8; 32]);
23
24impl SubjectKey {
25 pub const fn from_digest(digest: [u8; 32]) -> Self {
29 Self(digest)
30 }
31
32 pub const fn as_bytes(&self) -> &[u8; 32] {
34 &self.0
35 }
36
37 pub const fn into_bytes(self) -> [u8; 32] {
39 self.0
40 }
41}
42
43impl fmt::Debug for SubjectKey {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter.write_str("SubjectKey([REDACTED])")
46 }
47}
48
49pub struct KeyHasher {
64 template: HmacSha256,
65}
66
67impl Clone for KeyHasher {
68 fn clone(&self) -> Self {
69 Self {
70 template: self.template.clone(),
71 }
72 }
73}
74
75impl KeyHasher {
76 pub const MINIMUM_SECRET_LENGTH: usize = 32;
78
79 pub fn new(secret: impl AsRef<[u8]>) -> Result<Self, KeyHasherError> {
89 let secret = secret.as_ref();
90 if secret.len() < Self::MINIMUM_SECRET_LENGTH {
91 return Err(KeyHasherError::SecretTooShort {
92 actual: secret.len(),
93 minimum: Self::MINIMUM_SECRET_LENGTH,
94 });
95 }
96
97 let Ok(mut template) = HmacSha256::new_from_slice(secret) else {
98 unreachable!("HMAC-SHA-256 accepts keys of every length");
99 };
100 template.update(KEY_DOMAIN);
101
102 Ok(Self { template })
103 }
104
105 pub fn hash(
110 &self,
111 policy_id: &PolicyId,
112 scope_id: &ScopeId,
113 subject: impl AsRef<[u8]>,
114 ) -> SubjectKey {
115 let mut mac = self.template.clone();
116 mac.update(policy_id.as_str().as_bytes());
117 mac.update(&[0]);
118 mac.update(scope_id.as_str().as_bytes());
119 mac.update(&[0]);
120 mac.update(subject.as_ref());
121 SubjectKey::from_digest(mac.finalize().into_bytes().into())
122 }
123
124 pub fn hash_for<P: RateLimitPolicy + ?Sized>(
126 &self,
127 policy: &P,
128 subject: impl AsRef<[u8]>,
129 ) -> SubjectKey {
130 self.hash(policy.id(), policy.scope(), subject)
131 }
132}
133
134impl fmt::Debug for KeyHasher {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter.write_str("KeyHasher([REDACTED])")
137 }
138}
139
140#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
142pub enum KeyHasherError {
143 #[error("key-hashing secret is {actual} bytes; at least {minimum} bytes are required")]
145 SecretTooShort {
146 actual: usize,
148 minimum: usize,
150 },
151}
152
153#[cfg(test)]
154mod tests {
155 use std::time::Duration;
156
157 use super::{KeyHasher, KeyHasherError, SubjectKey};
158 use crate::{FixedWindowPolicy, PolicyId, ScopeId};
159
160 fn hasher() -> KeyHasher {
161 KeyHasher::new([0x42; 32]).unwrap()
162 }
163
164 fn policy(id: &str, scope: &str) -> FixedWindowPolicy {
165 FixedWindowPolicy::new(
166 PolicyId::new(id).unwrap(),
167 ScopeId::new(scope).unwrap(),
168 8,
169 Duration::from_secs(60),
170 )
171 .unwrap()
172 }
173
174 #[test]
175 fn rejects_short_secrets() {
176 assert_eq!(
177 KeyHasher::new([0; 31]).unwrap_err(),
178 KeyHasherError::SecretTooShort {
179 actual: 31,
180 minimum: 32,
181 }
182 );
183 }
184
185 #[test]
186 fn accepts_secrets_longer_than_the_minimum() {
187 assert!(KeyHasher::new([0; 64]).is_ok());
188 }
189
190 #[test]
191 fn hashing_is_deterministic_within_a_namespace() {
192 let policy = policy("auth.login", "identity");
193 let first = hasher().hash_for(&policy, b"user@example.test");
194 let second = hasher().hash_for(&policy, b"user@example.test");
195
196 assert_eq!(first, second);
197 }
198
199 #[test]
200 fn policy_and_scope_domain_separate_subjects() {
201 let hasher = hasher();
202 let login_identity =
203 hasher.hash_for(&policy("auth.login", "identity"), b"user@example.test");
204 let signup_identity =
205 hasher.hash_for(&policy("auth.signup", "identity"), b"user@example.test");
206 let login_client = hasher.hash_for(&policy("auth.login", "client"), b"user@example.test");
207
208 assert_ne!(login_identity, signup_identity);
209 assert_ne!(login_identity, login_client);
210 }
211
212 #[test]
213 fn subjects_and_secrets_change_the_digest() {
214 let policy = policy("auth.login", "identity");
215 let first = hasher().hash_for(&policy, b"first");
216 let second = hasher().hash_for(&policy, b"second");
217 let other_secret = KeyHasher::new([0x24; 32])
218 .unwrap()
219 .hash_for(&policy, b"first");
220
221 assert_ne!(first, second);
222 assert_ne!(first, other_secret);
223 }
224
225 #[test]
226 fn subject_key_debug_output_is_redacted() {
227 let key = SubjectKey::from_digest([0xab; 32]);
228 let output = format!("{key:?}");
229
230 assert_eq!(output, "SubjectKey([REDACTED])");
231 assert!(!output.contains("ab"));
232 assert_eq!(key.as_bytes(), &[0xab; 32]);
233 assert_eq!(key.into_bytes(), [0xab; 32]);
234 }
235
236 #[test]
237 fn hasher_debug_output_is_redacted() {
238 assert_eq!(format!("{:?}", hasher()), "KeyHasher([REDACTED])");
239 }
240
241 #[test]
242 fn hashing_matches_stable_protocol_vectors() {
243 let policy = policy("auth.login", "identity");
244
245 assert_eq!(
246 hasher()
247 .hash_for(&policy, b"user@example.test")
248 .into_bytes(),
249 [
250 0x7e, 0x8c, 0x35, 0x4d, 0x1a, 0x9b, 0x8c, 0x11, 0xeb, 0xf5, 0xfd, 0x5f, 0xcb, 0x82,
251 0x58, 0x6f, 0xda, 0xce, 0xbe, 0xf1, 0xff, 0x15, 0x82, 0x9f, 0xe0, 0xb0, 0x79, 0xd1,
252 0x31, 0x22, 0xbc, 0x21,
253 ]
254 );
255 assert_eq!(
256 KeyHasher::new([0x24; 80])
257 .unwrap()
258 .hash_for(&policy, b"user@example.test")
259 .into_bytes(),
260 [
261 0x23, 0xa7, 0x30, 0xd0, 0x57, 0x8e, 0xec, 0x28, 0xe3, 0xf5, 0x7d, 0xd3, 0x96, 0x32,
262 0xd8, 0xd8, 0x7b, 0x99, 0x87, 0x79, 0x56, 0xc9, 0xcd, 0x7d, 0xfe, 0x26, 0x84, 0x7b,
263 0x17, 0x61, 0x8b, 0xd0,
264 ]
265 );
266 }
267
268 #[test]
269 fn cloned_hasher_uses_independently_zeroizing_keyed_state() {
270 let policy = policy("auth.login", "identity");
271 let original = hasher();
272 let cloned = original.clone();
273 let expected = original.hash_for(&policy, b"user@example.test");
274
275 drop(original);
276
277 assert_eq!(
278 cloned.hash_for(&policy, b"user@example.test"),
279 expected,
280 "dropping the original must not invalidate the clone"
281 );
282 assert_eq!(format!("{cloned:?}"), "KeyHasher([REDACTED])");
283 }
284}