Skip to main content

rskit_auth/apikey/
key.rs

1//! API key generation, peppered digest storage, and validation helpers.
2
3use std::fmt;
4
5use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
6use chrono::{DateTime, Utc};
7use hmac::{Hmac, KeyInit, Mac};
8use rskit_errors::{AppError, AppResult, ErrorCode};
9use serde::{Deserialize, Serialize};
10use sha2::Sha256;
11
12type HmacSha256 = Hmac<Sha256>;
13
14const DEFAULT_ENTROPY_BYTES: usize = 32;
15const MIN_ENTROPY_BYTES: usize = 16;
16const MIN_PEPPER_BYTES: usize = 32;
17
18/// API key hashing configuration.
19#[derive(Clone, Deserialize)]
20pub struct HashingConfig {
21    /// Secret pepper used for HMAC-SHA-256 digest storage.
22    pub pepper: String,
23    /// Random secret entropy in bytes.
24    pub entropy_bytes: usize,
25}
26
27impl HashingConfig {
28    /// Create a hashing configuration with the default entropy budget.
29    #[must_use]
30    pub fn new(pepper: impl Into<String>) -> Self {
31        Self {
32            pepper: pepper.into(),
33            entropy_bytes: DEFAULT_ENTROPY_BYTES,
34        }
35    }
36
37    /// Validate the configuration.
38    pub fn validate(&self) -> AppResult<()> {
39        if self.pepper.len() < MIN_PEPPER_BYTES {
40            return Err(AppError::invalid_input(
41                "pepper",
42                format!("pepper must be at least {MIN_PEPPER_BYTES} bytes"),
43            ));
44        }
45        if self.entropy_bytes < MIN_ENTROPY_BYTES {
46            return Err(AppError::invalid_input(
47                "entropy_bytes",
48                format!("entropy_bytes must be at least {MIN_ENTROPY_BYTES}"),
49            ));
50        }
51        Ok(())
52    }
53}
54
55impl fmt::Debug for HashingConfig {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter
58            .debug_struct("HashingConfig")
59            .field("pepper", &"<redacted>")
60            .field("entropy_bytes", &self.entropy_bytes)
61            .finish()
62    }
63}
64
65/// Persisted API key metadata.
66#[derive(Clone, Serialize, Deserialize)]
67pub struct Key {
68    /// Unique identifier for this key.
69    pub id: String,
70    /// Identifier of the key owner.
71    pub owner_id: String,
72    /// Human-readable name for the key.
73    pub name: String,
74    /// Display-safe key prefix used for candidate lookup.
75    pub key_prefix: String,
76    /// Pepper-keyed HMAC-SHA-256 digest stored at rest.
77    pub key_digest: String,
78    /// Scopes granted to the key.
79    pub scopes: Vec<String>,
80    /// Whether the key is active.
81    pub is_active: bool,
82    /// Expiry time.
83    pub expires_at: Option<DateTime<Utc>>,
84    /// Grace period end after rotation.
85    pub grace_ends_at: Option<DateTime<Utc>>,
86    /// Replacement key ID after rotation.
87    pub rotated_by_id: Option<String>,
88    /// Last successful validation time.
89    pub last_used_at: Option<DateTime<Utc>>,
90    /// Creation timestamp.
91    pub created_at: DateTime<Utc>,
92}
93
94impl fmt::Debug for Key {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter
97            .debug_struct("Key")
98            .field("id", &self.id)
99            .field("owner_id", &self.owner_id)
100            .field("name", &self.name)
101            .field("key_prefix", &self.key_prefix)
102            .field("key_digest", &"<redacted>")
103            .field("scopes", &self.scopes)
104            .field("is_active", &self.is_active)
105            .field("expires_at", &self.expires_at)
106            .field("grace_ends_at", &self.grace_ends_at)
107            .field("rotated_by_id", &self.rotated_by_id)
108            .field("last_used_at", &self.last_used_at)
109            .field("created_at", &self.created_at)
110            .finish()
111    }
112}
113
114impl Key {
115    /// Return `true` when the key is expired and beyond any grace period.
116    #[must_use]
117    pub fn is_expired_past_grace(&self) -> bool {
118        let now = Utc::now();
119        if let Some(grace_ends_at) = self.grace_ends_at
120            && now > grace_ends_at
121        {
122            return true;
123        }
124        if let Some(expires_at) = self.expires_at
125            && now > expires_at
126            && self.grace_ends_at.is_none()
127        {
128            return true;
129        }
130        false
131    }
132}
133
134/// One-time API key material returned to callers.
135#[derive(Clone, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
136pub struct GenerateResult {
137    /// Plaintext key shown once.
138    pub plain_key: String,
139    /// Prefix stored alongside metadata.
140    pub key_prefix: String,
141    /// Pepper-keyed digest stored at rest.
142    pub key_digest: String,
143}
144
145impl fmt::Debug for GenerateResult {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        formatter
148            .debug_struct("GenerateResult")
149            .field("plain_key", &"<redacted>")
150            .field("key_prefix", &self.key_prefix)
151            .field("key_digest", &"<redacted>")
152            .finish()
153    }
154}
155
156/// API key hasher and issuer.
157///
158/// The HMAC key is validated and pre-initialized at construction time so that
159/// `digest` and `compare` are fully infallible at call time.
160#[derive(Clone)]
161pub struct Hasher {
162    config: HashingConfig,
163    /// Pre-initialized HMAC state; cloned cheaply for each digest call.
164    hmac_prototype: HmacSha256,
165}
166
167impl fmt::Debug for Hasher {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("Hasher")
170            .field("config", &self.config)
171            .field("hmac_prototype", &"<redacted>")
172            .finish()
173    }
174}
175
176impl Hasher {
177    /// Construct a new hasher, validating config and pre-initializing the HMAC key.
178    pub fn new(mut config: HashingConfig) -> AppResult<Self> {
179        if config.entropy_bytes == 0 {
180            config.entropy_bytes = DEFAULT_ENTROPY_BYTES;
181        }
182        config.validate()?;
183        let hmac_prototype =
184            HmacSha256::new_from_slice(config.pepper.as_bytes()).map_err(|error| {
185                AppError::new(
186                    ErrorCode::InvalidInput,
187                    format!("invalid API key pepper: {error}"),
188                )
189            })?;
190        Ok(Self {
191            config,
192            hmac_prototype,
193        })
194    }
195
196    /// Return the active configuration.
197    #[must_use]
198    pub const fn config(&self) -> &HashingConfig {
199        &self.config
200    }
201
202    /// Generate a new API key with the supplied prefix.
203    pub fn generate_key(&self, prefix: &str) -> AppResult<GenerateResult> {
204        validate_prefix(prefix)?;
205
206        let mut random_bytes = vec![0_u8; self.config.entropy_bytes];
207        rand::fill(random_bytes.as_mut_slice());
208        let secret = URL_SAFE_NO_PAD.encode(random_bytes);
209        let plain_key = format!("{prefix}.{secret}");
210
211        Ok(GenerateResult {
212            key_prefix: prefix.to_string(),
213            key_digest: self.digest(&plain_key),
214            plain_key,
215        })
216    }
217
218    /// Compute the peppered HMAC-SHA-256 digest for a plaintext key.
219    ///
220    /// Infallible — the HMAC key was validated at construction.
221    #[must_use]
222    pub fn digest(&self, plain_key: &str) -> String {
223        let mut mac = self.hmac_prototype.clone();
224        mac.update(plain_key.as_bytes());
225        hex::encode(mac.finalize().into_bytes())
226    }
227
228    /// Constant-time digest comparison.
229    #[must_use]
230    pub fn compare(&self, plain_key: &str, stored_digest: &str) -> bool {
231        let computed = self.digest(plain_key);
232        subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), stored_digest.as_bytes()).into()
233    }
234}
235
236/// Split a plaintext key into `(prefix, secret)`.
237pub fn split_key(plain_key: &str) -> AppResult<(String, String)> {
238    let Some((prefix, secret)) = plain_key.split_once('.') else {
239        return Err(AppError::invalid_input("api_key", "invalid API key format"));
240    };
241    if prefix.is_empty() || secret.is_empty() {
242        return Err(AppError::invalid_input("api_key", "invalid API key format"));
243    }
244    Ok((prefix.to_string(), secret.to_string()))
245}
246
247/// Error returned when a key is not usable.
248#[derive(Debug, Clone, thiserror::Error)]
249#[non_exhaustive]
250pub enum KeyValidationError {
251    /// The key has been revoked.
252    #[error("key is revoked")]
253    Revoked,
254    /// The key is expired past its grace period.
255    #[error("key is expired")]
256    Expired,
257}
258
259/// Validate key usability.
260pub fn validate(key: &Key) -> Result<(), KeyValidationError> {
261    if !key.is_active {
262        return Err(KeyValidationError::Revoked);
263    }
264    if key.is_expired_past_grace() {
265        return Err(KeyValidationError::Expired);
266    }
267    Ok(())
268}
269
270fn validate_prefix(prefix: &str) -> AppResult<()> {
271    if prefix.is_empty() {
272        return Err(AppError::invalid_input(
273            "prefix",
274            "prefix must be non-empty",
275        ));
276    }
277    if prefix
278        .chars()
279        .any(|char| !char.is_ascii_alphanumeric() && char != '-' && char != '_')
280    {
281        return Err(AppError::invalid_input(
282            "prefix",
283            "prefix must contain only [A-Za-z0-9_-]",
284        ));
285    }
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::{GenerateResult, Hasher, HashingConfig, Key, split_key};
292    use chrono::Utc;
293
294    #[test]
295    fn hashing_config_debug_redacts_pepper() {
296        let formatted = format!("{:?}", HashingConfig::new("very-secret-pepper-material"));
297        assert!(formatted.contains("<redacted>"));
298        assert!(!formatted.contains("very-secret-pepper-material"));
299    }
300
301    #[test]
302    fn generate_result_debug_redacts_plain_key() {
303        let formatted = format!(
304            "{:?}",
305            GenerateResult {
306                plain_key: "sk_live.secret".into(),
307                key_prefix: "sk_live".into(),
308                key_digest: "digest".into(),
309            }
310        );
311        assert!(formatted.contains("<redacted>"));
312        assert!(!formatted.contains("sk_live.secret"));
313    }
314
315    #[test]
316    fn key_and_hasher_debug_redact_secret_material() {
317        let hasher =
318            Hasher::new(HashingConfig::new("very-secret-pepper-material-32-bytes")).unwrap();
319        let key = Key {
320            id: "key-1".into(),
321            owner_id: "owner-1".into(),
322            name: "primary".into(),
323            key_prefix: "pk".into(),
324            key_digest: "stored-digest".into(),
325            scopes: vec!["read".into()],
326            is_active: true,
327            expires_at: None,
328            grace_ends_at: None,
329            rotated_by_id: None,
330            last_used_at: None,
331            created_at: Utc::now(),
332        };
333
334        let hasher_debug = format!("{hasher:?}");
335        assert!(hasher_debug.contains("<redacted>"));
336        assert!(!hasher_debug.contains("very-secret-pepper-material-32-bytes"));
337
338        let key_debug = format!("{key:?}");
339        assert!(key_debug.contains("<redacted>"));
340        assert!(!key_debug.contains("stored-digest"));
341    }
342
343    #[test]
344    fn empty_api_key_prefix_is_rejected() {
345        let hasher =
346            Hasher::new(HashingConfig::new("very-secret-pepper-material-32-bytes")).unwrap();
347
348        let error = hasher.generate_key("").unwrap_err();
349
350        assert_eq!(error.code(), rskit_errors::ErrorCode::InvalidInput);
351        assert!(error.message().contains("prefix"));
352    }
353
354    #[test]
355    fn generate_and_compare_roundtrip() {
356        let hasher = Hasher::new(HashingConfig {
357            pepper: "p".repeat(32),
358            entropy_bytes: 32,
359        })
360        .unwrap();
361        let issued = hasher.generate_key("pk").unwrap();
362        assert!(issued.plain_key.starts_with("pk."));
363        assert!(hasher.compare(&issued.plain_key, &issued.key_digest));
364    }
365
366    #[test]
367    fn split_key_rejects_malformed_values() {
368        assert!(split_key("pk.secret").is_ok());
369        assert!(split_key("malformed").is_err());
370    }
371
372    #[test]
373    fn hashing_config_rejects_short_pepper_and_entropy() {
374        let short_pepper = HashingConfig {
375            pepper: "short".into(),
376            entropy_bytes: 32,
377        };
378        assert!(short_pepper.validate().is_err());
379
380        let short_entropy = HashingConfig {
381            pepper: "p".repeat(32),
382            entropy_bytes: 1,
383        };
384        assert!(short_entropy.validate().is_err());
385    }
386
387    #[test]
388    fn key_debug_redacts_digest() {
389        let key = crate::apikey::Key {
390            id: "key-1".into(),
391            owner_id: "user-1".into(),
392            name: "primary".into(),
393            key_prefix: "pk".into(),
394            key_digest: "digest-secret".into(),
395            scopes: Vec::new(),
396            is_active: true,
397            expires_at: None,
398            grace_ends_at: None,
399            rotated_by_id: None,
400            last_used_at: None,
401            created_at: chrono::Utc::now(),
402        };
403
404        let formatted = format!("{key:?}");
405
406        assert!(formatted.contains("<redacted>"));
407        assert!(!formatted.contains("digest-secret"));
408    }
409}