Skip to main content

runlimit_core/
key.rs

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/// An opaque, fixed-width subject identifier used by storage backends.
14///
15/// The inner digest is intentionally omitted from [`Debug`] output. Construct
16/// keys with [`KeyHasher`] unless the input is already a cryptographically
17/// opaque 32-byte digest.
18///
19/// This type deliberately does not implement Serde traits, even when the
20/// crate's `serde` feature is enabled, to avoid accidental key disclosure.
21#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct SubjectKey([u8; 32]);
23
24impl SubjectKey {
25    /// Constructs a key from an already-opaque 32-byte digest.
26    ///
27    /// This constructor does not hash or otherwise transform the input.
28    pub const fn from_digest(digest: [u8; 32]) -> Self {
29        Self(digest)
30    }
31
32    /// Returns the opaque digest bytes for storage and comparison.
33    pub const fn as_bytes(&self) -> &[u8; 32] {
34        &self.0
35    }
36
37    /// Consumes the key and returns its opaque digest bytes.
38    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
49/// Derives opaque subject keys using HMAC-SHA-256.
50///
51/// Each derivation is domain-separated by the exact policy and scope
52/// identifiers. The same normalized subject therefore yields unrelated keys
53/// in different policy scopes.
54///
55/// Applications should keep one stable secret per deployment. Rotating it
56/// deliberately starts new counters because all derived subject keys change.
57///
58/// The raw secret is not retained after construction. Instead, the hasher
59/// caches key-equivalent, precomputed HMAC state. Cloning a hasher copies that
60/// state; each copy zeroizes its SHA-256 state and buffered input when dropped.
61/// Treat a live [`KeyHasher`] as secret material. [`Debug`](fmt::Debug) never
62/// exposes its state.
63pub 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    /// Minimum accepted secret length in bytes.
77    pub const MINIMUM_SECRET_LENGTH: usize = 32;
78
79    /// Constructs a hasher by precomputing zeroizing keyed HMAC state.
80    ///
81    /// The supplied raw secret is borrowed only for construction and is not
82    /// retained by the returned hasher.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`KeyHasherError::SecretTooShort`] unless the secret contains at
87    /// least 32 bytes.
88    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    /// Hashes a normalized subject within an explicit policy and scope.
106    ///
107    /// Normalization is application-owned: two byte strings are treated as
108    /// distinct subjects even if an application considers them equivalent.
109    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    /// Hashes a normalized subject in a rate-limit policy's namespace.
125    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/// An invalid subject-key hasher configuration.
141#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
142pub enum KeyHasherError {
143    /// The supplied secret was shorter than the security minimum.
144    #[error("key-hashing secret is {actual} bytes; at least {minimum} bytes are required")]
145    SecretTooShort {
146        /// Supplied secret length.
147        actual: usize,
148        /// Minimum accepted secret length.
149        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}