Skip to main content

runlimit_core/
key.rs

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/// An opaque, fixed-width subject identifier used by storage backends.
15///
16/// The inner digest is intentionally omitted from [`Debug`] output. Construct
17/// keys with [`KeyHasher`] unless the input is already a cryptographically
18/// opaque 32-byte digest.
19#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct SubjectKey([u8; 32]);
21
22impl SubjectKey {
23    /// Constructs a key from an already-opaque 32-byte digest.
24    ///
25    /// This constructor does not hash or otherwise transform the input.
26    pub const fn from_digest(digest: [u8; 32]) -> Self {
27        Self(digest)
28    }
29
30    /// Returns the opaque digest bytes for storage and comparison.
31    pub const fn as_bytes(&self) -> &[u8; 32] {
32        &self.0
33    }
34
35    /// Consumes the key and returns its opaque digest bytes.
36    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
47/// Derives opaque subject keys using HMAC-SHA-256.
48///
49/// Each derivation is domain-separated by the exact policy and scope
50/// identifiers. The same normalized subject therefore yields unrelated keys
51/// in different policy scopes.
52///
53/// Applications should keep one stable secret per deployment. Rotating it
54/// deliberately starts new counters because all derived subject keys change.
55pub struct KeyHasher {
56    secret: Zeroizing<Vec<u8>>,
57}
58
59impl KeyHasher {
60    /// Minimum accepted secret length in bytes.
61    pub const MINIMUM_SECRET_LENGTH: usize = 32;
62
63    /// Constructs a hasher by copying a secret into zeroizing storage.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`KeyHasherError::SecretTooShort`] unless the secret contains at
68    /// least 32 bytes.
69    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    /// Hashes a normalized subject within an explicit policy and scope.
84    ///
85    /// Normalization is application-owned: two byte strings are treated as
86    /// distinct subjects even if an application considers them equivalent.
87    pub fn hash(
88        &self,
89        policy_id: &PolicyId,
90        scope_id: &ScopeId,
91        subject: impl AsRef<[u8]>,
92    ) -> SubjectKey {
93        // Construct this state per derivation: hmac 0.12's cloneable keyed
94        // state does not implement Zeroize, so caching it would retain a
95        // second long-lived, key-equivalent secret outside `self.secret`.
96        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    /// Hashes a normalized subject in a fixed-window policy's namespace.
109    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/// An invalid subject-key hasher configuration.
121#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
122pub enum KeyHasherError {
123    /// The supplied secret was shorter than the security minimum.
124    #[error("key-hashing secret is {actual} bytes; at least {minimum} bytes are required")]
125    SecretTooShort {
126        /// Supplied secret length.
127        actual: usize,
128        /// Minimum accepted secret length.
129        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}