Skip to main content

jerrycan_auth/
api_key.rs

1//! Scoped API keys (spec §v2.4 Task 2): mint a high-entropy key, store only its
2//! SHA-256 hash, authenticate requests, and scope-check them.
3//!
4//! ## Why SHA-256, not argon2
5//! API keys are machine-generated with 256 bits of entropy ([`mint`] draws 32
6//! bytes from the OS CSPRNG), so brute-forcing the preimage is infeasible
7//! regardless of hash speed. argon2 buys nothing here and would make the lookup
8//! column slow and variable-length. A plain hex SHA-256 gives a fast, fixed-width
9//! (64-char) lookup key — exactly what a DB index wants.
10//!
11//! ## Why the verify is constant-time
12//! [`verify`] hashes the presented plaintext and compares the two **32-byte
13//! digests** in constant time via `hmac`'s `verify_slice` (the same primitive the
14//! webhook code uses). It deliberately does NOT compare the hex `String`s with
15//! `==`: `String`/`&str` equality short-circuits on the first differing byte,
16//! which leaks, through timing, how many leading hex chars of the stored hash a
17//! guess matched. Comparing the raw digests in fixed time closes that channel.
18//! (The stored hash is not itself a secret the way an HMAC key is, but verifying
19//! in constant time is cheap and removes the channel by construction.)
20//!
21//! ## DI: how the store reaches the extractor
22//! [`ApiKey`] resolves the store as the concrete newtype [`ApiKeys`] (which wraps
23//! `Arc<dyn ApiKeyStore>`), NOT a bare `Arc<dyn ApiKeyStore>`. The DI layer keys
24//! providers on `TypeId` and round-trips a bare trait-object `Arc` just fine, but
25//! the newtype is the documented, unambiguous contract: the app calls
26//! `app.provide(ApiKeys::new(store))` and the extractor resolves `ApiKeys`. See
27//! the `bare_arc_dyn_also_round_trips_through_di` test for evidence the bare form
28//! works too; we still standardize on the newtype.
29
30use 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
41/// A freshly minted key. The `plaintext` is shown to the operator exactly once
42/// and is NEVER stored — only its `hash` (and `prefix`) are persisted.
43pub struct MintedApiKey {
44    /// The full secret to hand to the caller once: `{prefix}_{base64url-random}`.
45    pub plaintext: String,
46    /// The human-readable label prefix (e.g. `"sk_live"`), stored for display.
47    pub prefix: String,
48    /// Hex SHA-256 of the full plaintext — the value to persist and index on.
49    pub hash: String,
50}
51
52/// Mint a new API key: 32 bytes from the OS CSPRNG, formatted as
53/// `{prefix}_{base64url_nopad(random)}`. The returned [`MintedApiKey::hash`] is
54/// what you store; [`MintedApiKey::plaintext`] is shown once and discarded.
55pub 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
68/// Hex SHA-256 of the full key plaintext — the value stored in the lookup column.
69pub 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
75/// Constant-time check that `plaintext` hashes to `stored_hash`.
76///
77/// Hashes `plaintext` and compares the two raw 32-byte digests in fixed time
78/// (via `hmac`'s `verify_slice`), so the comparison can't leak a partial match
79/// through timing. A malformed (non-hex / wrong-length) `stored_hash` simply
80/// fails — never panics. See the module docs for why this is not a `String ==`.
81pub 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    // `verify_slice` is a constant-time tag comparison. We MAC the candidate
94    // digest under a fixed zero key and compare against the stored digest MAC'd
95    // the same way — equivalently, just compare the digests in fixed time. Using
96    // the HMAC machinery keeps us on the vetted constant-time path rather than a
97    // hand-rolled loop. The key is constant, so this adds no secret material.
98    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
107/// Decode a 64-char lowercase/uppercase hex string into a 32-byte digest.
108/// Returns `None` for the wrong length or any non-hex char — never panics.
109fn 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/// A stored API-key record: its DB id, display prefix, hex hash, and scopes.
124#[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    /// `Ok(())` if this key carries `needed` (or the `"*"` wildcard); else 403.
134    pub fn require_scope(&self, needed: &str) -> Result<()> {
135        require_scope(&self.scopes, needed)
136    }
137}
138
139/// Scope check (mirrors [`crate::require_role`]): `Ok(())` when `scopes` contains
140/// `needed` or the wildcard `"*"`, otherwise `Error::forbidden()` (403). The
141/// wildcard is an admin/root grant — a `"*"` key passes every check.
142pub 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
150/// The boxed `Send` future every [`ApiKeyStore`] method returns. Hand-boxed (not
151/// `async-trait`) so the trait stays object-safe behind `dyn` — the same idiom
152/// as `jerrycan_jobs`'s `JobFuture` and `jerrycan_ratelimit`'s `HitFuture`.
153pub type ApiKeyFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
154
155/// Looks up a stored key by its hex hash. Object-safe so apps can back it with a
156/// DB table (returning `None` for an unknown hash, NOT an error).
157pub trait ApiKeyStore: Send + Sync {
158    fn lookup<'a>(&'a self, hash: &'a str) -> ApiKeyFuture<'a, Option<ApiKeyRecord>>;
159}
160
161/// In-memory store keyed by hex hash — for tests, the mock, and small deploys.
162/// Production apps implement [`ApiKeyStore`] over their database instead.
163#[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    /// Insert a record, indexed by its `hash`. Replaces any record with the same
174    /// hash (a hash collision would mean an identical key — practically never).
175    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/// The DI handle for an [`ApiKeyStore`]. Apps register the store with
197/// `app.provide(ApiKeys::new(store))`; the [`ApiKey`] extractor resolves this
198/// concrete type. A newtype (rather than a bare `Arc<dyn ApiKeyStore>`) is the
199/// documented contract — see the module docs.
200#[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    /// Wrap an already-`Arc`'d store (e.g. one shared with other components).
209    pub fn from_arc(store: Arc<dyn ApiKeyStore>) -> Self {
210        ApiKeys(store)
211    }
212}
213
214/// API-key extractor. Reads the key from `Authorization: Bearer <key>` (checked
215/// first) or `X-API-Key: <key>`, hashes it, looks it up in the provided
216/// [`ApiKeys`] store, and yields the matching [`ApiKeyRecord`]. A missing,
217/// malformed, or unknown key is a 401. Scope checks are a separate, explicit
218/// step ([`ApiKeyRecord::require_scope`]) so a handler decides what it needs.
219pub 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
234/// Pull the raw key from the headers: `Authorization: Bearer <key>` takes
235/// precedence over `X-API-Key: <key>`. Returns `None` if neither is present in a
236/// usable form. The `Bearer` scheme is matched case-insensitively (RFC 6750 §2.1:
237/// the auth-scheme token is case-insensitive), so `bearer`/`BEARER` also work.
238fn 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
251/// Strip a case-insensitive `Bearer ` scheme prefix, returning the token that
252/// follows. Only the scheme word is matched case-insensitively; the token itself
253/// is returned verbatim. Returns `None` if the header isn't a `Bearer` credential.
254fn 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    // ---- crypto core ----
265
266    #[test]
267    fn mint_then_verify_roundtrips_and_tamper_fails() {
268        let minted = mint("sk_live");
269        // The minted plaintext carries the prefix and a base64url random tail.
270        assert!(minted.plaintext.starts_with("sk_live_"));
271        assert_eq!(minted.prefix, "sk_live");
272        // A valid plaintext verifies against its stored hash...
273        assert!(verify(&minted.plaintext, &minted.hash));
274        // ...and any tamper (one char flipped) does not.
275        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        // Storing the hash must not leak the key: the random tail (the actual
284        // secret) must not appear anywhere in the stored hash, and the hash is a
285        // fixed 64-char hex string regardless of the key.
286        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        // Pins the algorithm: SHA-256("abc") is a well-known vector. Proves
304        // hash_key computes real SHA-256, not just something roundtrip-consistent.
305        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        // Documents the constant-time contract: `verify` works on the raw 32-byte
314        // digests, never `==` on the hex String (which short-circuits and leaks a
315        // partial-match prefix length via timing). A real timing test is out of
316        // scope; this asserts the BEHAVIOR that the implementation guarantees —
317        // a stored hash differing only in its LAST hex char must still reject,
318        // exactly as one differing in the first char does.
319        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        // Malformed stored hashes never panic and never verify.
331        assert!(!verify(&minted.plaintext, "not-hex"));
332        assert!(!verify(&minted.plaintext, ""));
333        assert!(!verify(&minted.plaintext, &"a".repeat(64))); // valid hex, wrong digest
334    }
335
336    // ---- scope checks ----
337
338    #[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        // A scope the key lacks is 403.
344        let err = require_scope(&scoped, "admin").unwrap_err();
345        assert_eq!(err.status(), StatusCode::FORBIDDEN);
346
347        // The wildcard passes any check.
348        let wild = vec!["*".to_string()];
349        assert!(require_scope(&wild, "anything").is_ok());
350        assert!(require_scope(&wild, "admin").is_ok());
351
352        // Empty scopes grant nothing.
353        assert_eq!(
354            require_scope(&[], "read").unwrap_err().status(),
355            StatusCode::FORBIDDEN
356        );
357
358        // The method mirrors the free fn.
359        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    // ---- full extractor path through a real App ----
373
374    /// A scope-gated handler: yields the record's prefix only if the key carries
375    /// the `reports:read` scope, so the test asserts the resolved record AND the
376    /// scope enforcement in one path.
377    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        // A scoped key with the required scope.
385        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        // A key WITHOUT the required scope (only "other").
393        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        // X-API-Key header form.
412        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        // Authorization: Bearer form — both header schemes must work. RFC 6750 §2.1
428        // makes the scheme token case-insensitive, so every casing must resolve.
429        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        // No key at all.
450        assert_eq!(t.get("/reports").await.status(), StatusCode::UNAUTHORIZED);
451        // A key that isn't in the store.
452        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        // An Authorization header that isn't a Bearer token.
457        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        // The key authenticates (200-eligible) but lacks `reports:read` → 403.
472        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        // When both headers are present, Bearer wins. Put the VALID key in
502        // Authorization and a garbage value in X-API-Key: success proves Bearer
503        // was read first.
504        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    /// Evidence for the DI decision (documented in the module docs): a bare
522    /// `Arc<dyn ApiKeyStore>` DOES round-trip through `provide`/`resolve` (the DI
523    /// keys on `TypeId`, and `TypeId::of::<Arc<dyn ApiKeyStore>>()` is stable).
524    /// We still standardize the extractor on the `ApiKeys` newtype; this test
525    /// just records that the bare form works, so the choice is by convention, not
526    /// necessity.
527    #[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            // Resolving and using it proves the round-trip; the lookup of an
533            // absent hash returns Ok(None).
534            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}