use crate::webhook::hex_encode;
use base64::Engine;
use chacha20poly1305::aead::OsRng;
use jerrycan_core::{Error, FromRequest, Headers, RequestCtx, Result};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
pub struct MintedApiKey {
pub plaintext: String,
pub prefix: String,
pub hash: String,
}
pub fn mint(prefix: &str) -> MintedApiKey {
let mut random = [0u8; 32];
OsRng.fill_bytes(&mut random);
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random);
let plaintext = format!("{prefix}_{encoded}");
let hash = hash_key(&plaintext);
MintedApiKey {
plaintext,
prefix: prefix.to_string(),
hash,
}
}
pub fn hash_key(plaintext: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(plaintext.as_bytes());
hex_encode(&hasher.finalize())
}
pub fn verify(plaintext: &str, stored_hash: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256 as Sha256Mac;
let Some(stored) = decode_hex_digest(stored_hash) else {
return false;
};
let mut digest = [0u8; 32];
let mut hasher = Sha256::new();
hasher.update(plaintext.as_bytes());
digest.copy_from_slice(&hasher.finalize());
let mut mac =
Hmac::<Sha256Mac>::new_from_slice(&[0u8; 32]).expect("hmac accepts any key length");
mac.update(&stored);
let mut expected =
Hmac::<Sha256Mac>::new_from_slice(&[0u8; 32]).expect("hmac accepts any key length");
expected.update(&digest);
mac.verify_slice(&expected.finalize().into_bytes()).is_ok()
}
fn decode_hex_digest(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let bytes = s.as_bytes();
let mut out = [0u8; 32];
for (i, pair) in bytes.chunks_exact(2).enumerate() {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out[i] = (hi * 16 + lo) as u8;
}
Some(out)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiKeyRecord {
pub id: i64,
pub prefix: String,
pub hash: String,
pub scopes: Vec<String>,
}
impl ApiKeyRecord {
pub fn require_scope(&self, needed: &str) -> Result<()> {
require_scope(&self.scopes, needed)
}
}
pub fn require_scope(scopes: &[String], needed: &str) -> Result<()> {
if scopes.iter().any(|s| s == "*" || s == needed) {
Ok(())
} else {
Err(Error::forbidden())
}
}
pub type ApiKeyFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
pub trait ApiKeyStore: Send + Sync {
fn lookup<'a>(&'a self, hash: &'a str) -> ApiKeyFuture<'a, Option<ApiKeyRecord>>;
}
#[derive(Default)]
pub struct InMemoryApiKeyStore {
keys: Mutex<HashMap<String, ApiKeyRecord>>,
}
impl InMemoryApiKeyStore {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, record: ApiKeyRecord) {
self.keys
.lock()
.expect("api-key store mutex poisoned")
.insert(record.hash.clone(), record);
}
}
impl ApiKeyStore for InMemoryApiKeyStore {
fn lookup<'a>(&'a self, hash: &'a str) -> ApiKeyFuture<'a, Option<ApiKeyRecord>> {
Box::pin(async move {
Ok(self
.keys
.lock()
.expect("api-key store mutex poisoned")
.get(hash)
.cloned())
})
}
}
#[derive(Clone)]
pub struct ApiKeys(pub Arc<dyn ApiKeyStore>);
impl ApiKeys {
pub fn new(store: impl ApiKeyStore + 'static) -> Self {
ApiKeys(Arc::new(store))
}
pub fn from_arc(store: Arc<dyn ApiKeyStore>) -> Self {
ApiKeys(store)
}
}
pub struct ApiKey(pub ApiKeyRecord);
impl FromRequest for ApiKey {
async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
let store = ctx.resolve::<ApiKeys>().await?;
let headers = Headers::from_request(ctx).await?;
let presented = extract_key(&headers).ok_or_else(Error::unauthorized)?;
let hash = hash_key(&presented);
match store.0.lookup(&hash).await? {
Some(record) => Ok(ApiKey(record)),
None => Err(Error::unauthorized()),
}
}
}
fn extract_key(headers: &Headers) -> Option<String> {
if let Some(auth) = headers.get("authorization")
&& let Some(token) = strip_bearer_prefix(auth)
&& !token.is_empty()
{
return Some(token.to_string());
}
headers
.get("x-api-key")
.filter(|v| !v.is_empty())
.map(str::to_string)
}
fn strip_bearer_prefix(auth: &str) -> Option<&str> {
let (scheme, token) = auth.split_once(' ')?;
scheme.eq_ignore_ascii_case("bearer").then_some(token)
}
#[cfg(test)]
mod tests {
use super::*;
use jerrycan_core::{App, Json, get, http::StatusCode};
#[test]
fn mint_then_verify_roundtrips_and_tamper_fails() {
let minted = mint("sk_live");
assert!(minted.plaintext.starts_with("sk_live_"));
assert_eq!(minted.prefix, "sk_live");
assert!(verify(&minted.plaintext, &minted.hash));
let mut tampered = minted.plaintext.clone();
tampered.push('x');
assert!(!verify(&tampered, &minted.hash));
assert!(!verify("sk_live_totally-different", &minted.hash));
}
#[test]
fn stored_hash_never_contains_the_plaintext_secret() {
let minted = mint("sk_live");
let random_tail = minted
.plaintext
.strip_prefix("sk_live_")
.expect("prefix present");
assert!(!random_tail.is_empty());
assert!(
!minted.hash.contains(random_tail),
"the stored hash must not embed the plaintext secret"
);
assert!(!minted.hash.contains(&minted.plaintext));
assert_eq!(minted.hash.len(), 64, "hex sha256 is fixed-width");
assert!(minted.hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn hash_key_matches_a_known_sha256_vector() {
assert_eq!(
hash_key("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_compares_digests_in_constant_time_not_the_hex_string() {
let minted = mint("sk_test");
let mut last_flipped = minted.hash.clone();
let last = last_flipped.pop().unwrap();
last_flipped.push(if last == '0' { '1' } else { '0' });
assert!(!verify(&minted.plaintext, &last_flipped));
let mut first_flipped = minted.hash.clone();
let first = first_flipped.remove(0);
first_flipped.insert(0, if first == '0' { '1' } else { '0' });
assert!(!verify(&minted.plaintext, &first_flipped));
assert!(!verify(&minted.plaintext, "not-hex"));
assert!(!verify(&minted.plaintext, ""));
assert!(!verify(&minted.plaintext, &"a".repeat(64))); }
#[test]
fn require_scope_allows_exact_and_wildcard_rejects_others() {
let scoped = vec!["read".to_string(), "write".to_string()];
assert!(require_scope(&scoped, "read").is_ok());
assert!(require_scope(&scoped, "write").is_ok());
let err = require_scope(&scoped, "admin").unwrap_err();
assert_eq!(err.status(), StatusCode::FORBIDDEN);
let wild = vec!["*".to_string()];
assert!(require_scope(&wild, "anything").is_ok());
assert!(require_scope(&wild, "admin").is_ok());
assert_eq!(
require_scope(&[], "read").unwrap_err().status(),
StatusCode::FORBIDDEN
);
let rec = ApiKeyRecord {
id: 1,
prefix: "sk".into(),
hash: "h".into(),
scopes: scoped,
};
assert!(rec.require_scope("read").is_ok());
assert_eq!(
rec.require_scope("admin").unwrap_err().status(),
StatusCode::FORBIDDEN
);
}
async fn reports(ApiKey(key): ApiKey) -> Result<Json<String>> {
key.require_scope("reports:read")?;
Ok(Json(key.prefix))
}
fn seed_store() -> (InMemoryApiKeyStore, MintedApiKey, MintedApiKey) {
let store = InMemoryApiKeyStore::new();
let scoped = mint("sk_live");
store.insert(ApiKeyRecord {
id: 1,
prefix: scoped.prefix.clone(),
hash: scoped.hash.clone(),
scopes: vec!["reports:read".into()],
});
let unscoped = mint("sk_other");
store.insert(ApiKeyRecord {
id: 2,
prefix: unscoped.prefix.clone(),
hash: unscoped.hash.clone(),
scopes: vec!["other".into()],
});
(store, scoped, unscoped)
}
#[tokio::test]
async fn valid_x_api_key_resolves_record_and_passes_scope() {
let (store, scoped, _unscoped) = seed_store();
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
let res = t
.get_with("/reports", &[("x-api-key", &scoped.plaintext)])
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.json::<String>(), "sk_live");
}
#[tokio::test]
async fn valid_authorization_bearer_resolves_record() {
let (store, scoped, _unscoped) = seed_store();
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
for scheme in ["Bearer", "bearer", "BEARER", "BeArEr"] {
let header = format!("{scheme} {}", scoped.plaintext);
let res = t.get_with("/reports", &[("authorization", &header)]).await;
assert_eq!(
res.status(),
StatusCode::OK,
"scheme {scheme:?} must be accepted (RFC 6750 case-insensitive)"
);
assert_eq!(res.json::<String>(), "sk_live");
}
}
#[tokio::test]
async fn missing_or_garbage_key_is_401() {
let (store, _scoped, _unscoped) = seed_store();
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
assert_eq!(t.get("/reports").await.status(), StatusCode::UNAUTHORIZED);
let res = t
.get_with("/reports", &[("x-api-key", "sk_live_not-a-real-key")])
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let res = t
.get_with("/reports", &[("authorization", "Basic abc")])
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn valid_key_lacking_scope_is_403() {
let (store, _scoped, unscoped) = seed_store();
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
let res = t
.get_with("/reports", &[("x-api-key", &unscoped.plaintext)])
.await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn wildcard_key_passes_any_scope_check() {
let store = InMemoryApiKeyStore::new();
let admin = mint("sk_admin");
store.insert(ApiKeyRecord {
id: 9,
prefix: admin.prefix.clone(),
hash: admin.hash.clone(),
scopes: vec!["*".into()],
});
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
let res = t
.get_with("/reports", &[("x-api-key", &admin.plaintext)])
.await;
assert_eq!(res.status(), StatusCode::OK, "a `*` key passes any scope");
}
#[tokio::test]
async fn bearer_takes_precedence_over_x_api_key() {
let (store, scoped, _unscoped) = seed_store();
let app = App::new()
.provide(ApiKeys::new(store))
.route("/reports", get(reports));
let t = app.into_test();
let bearer = format!("Bearer {}", scoped.plaintext);
let res = t
.get_with(
"/reports",
&[("authorization", &bearer), ("x-api-key", "garbage")],
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.json::<String>(), "sk_live");
}
#[tokio::test]
async fn bare_arc_dyn_also_round_trips_through_di() {
use jerrycan_core::Dep;
async fn count_via_bare(dep: Dep<Arc<dyn ApiKeyStore>>) -> Result<Json<bool>> {
let found = dep.lookup("deadbeef").await?;
Ok(Json(found.is_none()))
}
let store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryApiKeyStore::new());
let app = App::new()
.provide(store)
.route("/probe", get(count_via_bare));
let res = app.into_test().get("/probe").await;
assert_eq!(res.status(), StatusCode::OK);
assert!(res.json::<bool>(), "bare Arc<dyn ApiKeyStore> resolved");
}
}