1use crate::webhook::hex_encode;
31use base64::Engine;
32use chacha20poly1305::aead::OsRng;
33use jerrycan_core::{Error, FromRequest, Headers, RequestCtx, Result};
34use rand::RngCore;
35use sha2::{Digest, Sha256};
36use std::collections::HashMap;
37use std::future::Future;
38use std::pin::Pin;
39use std::sync::{Arc, Mutex};
40
41pub struct MintedApiKey {
44 pub plaintext: String,
46 pub prefix: String,
48 pub hash: String,
50}
51
52pub fn mint(prefix: &str) -> MintedApiKey {
56 let mut random = [0u8; 32];
57 OsRng.fill_bytes(&mut random);
58 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random);
59 let plaintext = format!("{prefix}_{encoded}");
60 let hash = hash_key(&plaintext);
61 MintedApiKey {
62 plaintext,
63 prefix: prefix.to_string(),
64 hash,
65 }
66}
67
68pub fn hash_key(plaintext: &str) -> String {
70 let mut hasher = Sha256::new();
71 hasher.update(plaintext.as_bytes());
72 hex_encode(&hasher.finalize())
73}
74
75pub fn verify(plaintext: &str, stored_hash: &str) -> bool {
82 use hmac::{Hmac, Mac};
83 use sha2::Sha256 as Sha256Mac;
84
85 let Some(stored) = decode_hex_digest(stored_hash) else {
86 return false;
87 };
88 let mut digest = [0u8; 32];
89 let mut hasher = Sha256::new();
90 hasher.update(plaintext.as_bytes());
91 digest.copy_from_slice(&hasher.finalize());
92
93 let mut mac =
99 Hmac::<Sha256Mac>::new_from_slice(&[0u8; 32]).expect("hmac accepts any key length");
100 mac.update(&stored);
101 let mut expected =
102 Hmac::<Sha256Mac>::new_from_slice(&[0u8; 32]).expect("hmac accepts any key length");
103 expected.update(&digest);
104 mac.verify_slice(&expected.finalize().into_bytes()).is_ok()
105}
106
107fn decode_hex_digest(s: &str) -> Option<[u8; 32]> {
110 if s.len() != 64 {
111 return None;
112 }
113 let bytes = s.as_bytes();
114 let mut out = [0u8; 32];
115 for (i, pair) in bytes.chunks_exact(2).enumerate() {
116 let hi = (pair[0] as char).to_digit(16)?;
117 let lo = (pair[1] as char).to_digit(16)?;
118 out[i] = (hi * 16 + lo) as u8;
119 }
120 Some(out)
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ApiKeyRecord {
126 pub id: i64,
127 pub prefix: String,
128 pub hash: String,
129 pub scopes: Vec<String>,
130}
131
132impl ApiKeyRecord {
133 pub fn require_scope(&self, needed: &str) -> Result<()> {
135 require_scope(&self.scopes, needed)
136 }
137}
138
139pub fn require_scope(scopes: &[String], needed: &str) -> Result<()> {
143 if scopes.iter().any(|s| s == "*" || s == needed) {
144 Ok(())
145 } else {
146 Err(Error::forbidden())
147 }
148}
149
150pub type ApiKeyFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
154
155pub trait ApiKeyStore: Send + Sync {
158 fn lookup<'a>(&'a self, hash: &'a str) -> ApiKeyFuture<'a, Option<ApiKeyRecord>>;
159}
160
161#[derive(Default)]
164pub struct InMemoryApiKeyStore {
165 keys: Mutex<HashMap<String, ApiKeyRecord>>,
166}
167
168impl InMemoryApiKeyStore {
169 pub fn new() -> Self {
170 Self::default()
171 }
172
173 pub fn insert(&self, record: ApiKeyRecord) {
176 self.keys
177 .lock()
178 .expect("api-key store mutex poisoned")
179 .insert(record.hash.clone(), record);
180 }
181}
182
183impl ApiKeyStore for InMemoryApiKeyStore {
184 fn lookup<'a>(&'a self, hash: &'a str) -> ApiKeyFuture<'a, Option<ApiKeyRecord>> {
185 Box::pin(async move {
186 Ok(self
187 .keys
188 .lock()
189 .expect("api-key store mutex poisoned")
190 .get(hash)
191 .cloned())
192 })
193 }
194}
195
196#[derive(Clone)]
201pub struct ApiKeys(pub Arc<dyn ApiKeyStore>);
202
203impl ApiKeys {
204 pub fn new(store: impl ApiKeyStore + 'static) -> Self {
205 ApiKeys(Arc::new(store))
206 }
207
208 pub fn from_arc(store: Arc<dyn ApiKeyStore>) -> Self {
210 ApiKeys(store)
211 }
212}
213
214pub struct ApiKey(pub ApiKeyRecord);
220
221impl FromRequest for ApiKey {
222 async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
223 let store = ctx.resolve::<ApiKeys>().await?;
224 let headers = Headers::from_request(ctx).await?;
225 let presented = extract_key(&headers).ok_or_else(Error::unauthorized)?;
226 let hash = hash_key(&presented);
227 match store.0.lookup(&hash).await? {
228 Some(record) => Ok(ApiKey(record)),
229 None => Err(Error::unauthorized()),
230 }
231 }
232}
233
234fn extract_key(headers: &Headers) -> Option<String> {
239 if let Some(auth) = headers.get("authorization")
240 && let Some(token) = strip_bearer_prefix(auth)
241 && !token.is_empty()
242 {
243 return Some(token.to_string());
244 }
245 headers
246 .get("x-api-key")
247 .filter(|v| !v.is_empty())
248 .map(str::to_string)
249}
250
251fn strip_bearer_prefix(auth: &str) -> Option<&str> {
255 let (scheme, token) = auth.split_once(' ')?;
256 scheme.eq_ignore_ascii_case("bearer").then_some(token)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use jerrycan_core::{App, Json, get, http::StatusCode};
263
264 #[test]
267 fn mint_then_verify_roundtrips_and_tamper_fails() {
268 let minted = mint("sk_live");
269 assert!(minted.plaintext.starts_with("sk_live_"));
271 assert_eq!(minted.prefix, "sk_live");
272 assert!(verify(&minted.plaintext, &minted.hash));
274 let mut tampered = minted.plaintext.clone();
276 tampered.push('x');
277 assert!(!verify(&tampered, &minted.hash));
278 assert!(!verify("sk_live_totally-different", &minted.hash));
279 }
280
281 #[test]
282 fn stored_hash_never_contains_the_plaintext_secret() {
283 let minted = mint("sk_live");
287 let random_tail = minted
288 .plaintext
289 .strip_prefix("sk_live_")
290 .expect("prefix present");
291 assert!(!random_tail.is_empty());
292 assert!(
293 !minted.hash.contains(random_tail),
294 "the stored hash must not embed the plaintext secret"
295 );
296 assert!(!minted.hash.contains(&minted.plaintext));
297 assert_eq!(minted.hash.len(), 64, "hex sha256 is fixed-width");
298 assert!(minted.hash.chars().all(|c| c.is_ascii_hexdigit()));
299 }
300
301 #[test]
302 fn hash_key_matches_a_known_sha256_vector() {
303 assert_eq!(
306 hash_key("abc"),
307 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
308 );
309 }
310
311 #[test]
312 fn verify_compares_digests_in_constant_time_not_the_hex_string() {
313 let minted = mint("sk_test");
320 let mut last_flipped = minted.hash.clone();
321 let last = last_flipped.pop().unwrap();
322 last_flipped.push(if last == '0' { '1' } else { '0' });
323 assert!(!verify(&minted.plaintext, &last_flipped));
324
325 let mut first_flipped = minted.hash.clone();
326 let first = first_flipped.remove(0);
327 first_flipped.insert(0, if first == '0' { '1' } else { '0' });
328 assert!(!verify(&minted.plaintext, &first_flipped));
329
330 assert!(!verify(&minted.plaintext, "not-hex"));
332 assert!(!verify(&minted.plaintext, ""));
333 assert!(!verify(&minted.plaintext, &"a".repeat(64))); }
335
336 #[test]
339 fn require_scope_allows_exact_and_wildcard_rejects_others() {
340 let scoped = vec!["read".to_string(), "write".to_string()];
341 assert!(require_scope(&scoped, "read").is_ok());
342 assert!(require_scope(&scoped, "write").is_ok());
343 let err = require_scope(&scoped, "admin").unwrap_err();
345 assert_eq!(err.status(), StatusCode::FORBIDDEN);
346
347 let wild = vec!["*".to_string()];
349 assert!(require_scope(&wild, "anything").is_ok());
350 assert!(require_scope(&wild, "admin").is_ok());
351
352 assert_eq!(
354 require_scope(&[], "read").unwrap_err().status(),
355 StatusCode::FORBIDDEN
356 );
357
358 let rec = ApiKeyRecord {
360 id: 1,
361 prefix: "sk".into(),
362 hash: "h".into(),
363 scopes: scoped,
364 };
365 assert!(rec.require_scope("read").is_ok());
366 assert_eq!(
367 rec.require_scope("admin").unwrap_err().status(),
368 StatusCode::FORBIDDEN
369 );
370 }
371
372 async fn reports(ApiKey(key): ApiKey) -> Result<Json<String>> {
378 key.require_scope("reports:read")?;
379 Ok(Json(key.prefix))
380 }
381
382 fn seed_store() -> (InMemoryApiKeyStore, MintedApiKey, MintedApiKey) {
383 let store = InMemoryApiKeyStore::new();
384 let scoped = mint("sk_live");
386 store.insert(ApiKeyRecord {
387 id: 1,
388 prefix: scoped.prefix.clone(),
389 hash: scoped.hash.clone(),
390 scopes: vec!["reports:read".into()],
391 });
392 let unscoped = mint("sk_other");
394 store.insert(ApiKeyRecord {
395 id: 2,
396 prefix: unscoped.prefix.clone(),
397 hash: unscoped.hash.clone(),
398 scopes: vec!["other".into()],
399 });
400 (store, scoped, unscoped)
401 }
402
403 #[tokio::test]
404 async fn valid_x_api_key_resolves_record_and_passes_scope() {
405 let (store, scoped, _unscoped) = seed_store();
406 let app = App::new()
407 .provide(ApiKeys::new(store))
408 .route("/reports", get(reports));
409 let t = app.into_test();
410
411 let res = t
413 .get_with("/reports", &[("x-api-key", &scoped.plaintext)])
414 .await;
415 assert_eq!(res.status(), StatusCode::OK);
416 assert_eq!(res.json::<String>(), "sk_live");
417 }
418
419 #[tokio::test]
420 async fn valid_authorization_bearer_resolves_record() {
421 let (store, scoped, _unscoped) = seed_store();
422 let app = App::new()
423 .provide(ApiKeys::new(store))
424 .route("/reports", get(reports));
425 let t = app.into_test();
426
427 for scheme in ["Bearer", "bearer", "BEARER", "BeArEr"] {
430 let header = format!("{scheme} {}", scoped.plaintext);
431 let res = t.get_with("/reports", &[("authorization", &header)]).await;
432 assert_eq!(
433 res.status(),
434 StatusCode::OK,
435 "scheme {scheme:?} must be accepted (RFC 6750 case-insensitive)"
436 );
437 assert_eq!(res.json::<String>(), "sk_live");
438 }
439 }
440
441 #[tokio::test]
442 async fn missing_or_garbage_key_is_401() {
443 let (store, _scoped, _unscoped) = seed_store();
444 let app = App::new()
445 .provide(ApiKeys::new(store))
446 .route("/reports", get(reports));
447 let t = app.into_test();
448
449 assert_eq!(t.get("/reports").await.status(), StatusCode::UNAUTHORIZED);
451 let res = t
453 .get_with("/reports", &[("x-api-key", "sk_live_not-a-real-key")])
454 .await;
455 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
456 let res = t
458 .get_with("/reports", &[("authorization", "Basic abc")])
459 .await;
460 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
461 }
462
463 #[tokio::test]
464 async fn valid_key_lacking_scope_is_403() {
465 let (store, _scoped, unscoped) = seed_store();
466 let app = App::new()
467 .provide(ApiKeys::new(store))
468 .route("/reports", get(reports));
469 let t = app.into_test();
470
471 let res = t
473 .get_with("/reports", &[("x-api-key", &unscoped.plaintext)])
474 .await;
475 assert_eq!(res.status(), StatusCode::FORBIDDEN);
476 }
477
478 #[tokio::test]
479 async fn wildcard_key_passes_any_scope_check() {
480 let store = InMemoryApiKeyStore::new();
481 let admin = mint("sk_admin");
482 store.insert(ApiKeyRecord {
483 id: 9,
484 prefix: admin.prefix.clone(),
485 hash: admin.hash.clone(),
486 scopes: vec!["*".into()],
487 });
488 let app = App::new()
489 .provide(ApiKeys::new(store))
490 .route("/reports", get(reports));
491 let t = app.into_test();
492
493 let res = t
494 .get_with("/reports", &[("x-api-key", &admin.plaintext)])
495 .await;
496 assert_eq!(res.status(), StatusCode::OK, "a `*` key passes any scope");
497 }
498
499 #[tokio::test]
500 async fn bearer_takes_precedence_over_x_api_key() {
501 let (store, scoped, _unscoped) = seed_store();
505 let app = App::new()
506 .provide(ApiKeys::new(store))
507 .route("/reports", get(reports));
508 let t = app.into_test();
509
510 let bearer = format!("Bearer {}", scoped.plaintext);
511 let res = t
512 .get_with(
513 "/reports",
514 &[("authorization", &bearer), ("x-api-key", "garbage")],
515 )
516 .await;
517 assert_eq!(res.status(), StatusCode::OK);
518 assert_eq!(res.json::<String>(), "sk_live");
519 }
520
521 #[tokio::test]
528 async fn bare_arc_dyn_also_round_trips_through_di() {
529 use jerrycan_core::Dep;
530
531 async fn count_via_bare(dep: Dep<Arc<dyn ApiKeyStore>>) -> Result<Json<bool>> {
532 let found = dep.lookup("deadbeef").await?;
535 Ok(Json(found.is_none()))
536 }
537
538 let store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryApiKeyStore::new());
539 let app = App::new()
540 .provide(store)
541 .route("/probe", get(count_via_bare));
542 let res = app.into_test().get("/probe").await;
543 assert_eq!(res.status(), StatusCode::OK);
544 assert!(res.json::<bool>(), "bare Arc<dyn ApiKeyStore> resolved");
545 }
546}