1use 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#[derive(Clone, Deserialize)]
20pub struct HashingConfig {
21 pub pepper: String,
23 pub entropy_bytes: usize,
25}
26
27impl HashingConfig {
28 #[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 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#[derive(Clone, Serialize, Deserialize)]
67pub struct Key {
68 pub id: String,
70 pub owner_id: String,
72 pub name: String,
74 pub key_prefix: String,
76 pub key_digest: String,
78 pub scopes: Vec<String>,
80 pub is_active: bool,
82 pub expires_at: Option<DateTime<Utc>>,
84 pub grace_ends_at: Option<DateTime<Utc>>,
86 pub rotated_by_id: Option<String>,
88 pub last_used_at: Option<DateTime<Utc>>,
90 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 #[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#[derive(Clone, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
136pub struct GenerateResult {
137 pub plain_key: String,
139 pub key_prefix: String,
141 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#[derive(Clone)]
161pub struct Hasher {
162 config: HashingConfig,
163 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 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 #[must_use]
198 pub const fn config(&self) -> &HashingConfig {
199 &self.config
200 }
201
202 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 #[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 #[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
236pub 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#[derive(Debug, Clone, thiserror::Error)]
249#[non_exhaustive]
250pub enum KeyValidationError {
251 #[error("key is revoked")]
253 Revoked,
254 #[error("key is expired")]
256 Expired,
257}
258
259pub 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}