use core::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{Sha256, fill_random};
use crate::encoding::{hex_decode, hex_encode};
use crate::util::log::{debug, info, warn};
const KEY_PREFIX: &str = "esw";
const PREFIX_BYTE_LEN: usize = 4;
const SECRET_BYTE_LEN: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKeyErrorKind {
RandomFailure,
InvalidFormat,
InvalidPrefix,
InvalidSecret,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKeyError {
kind: ApiKeyErrorKind,
}
impl ApiKeyError {
const fn new(kind: ApiKeyErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for ApiKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
ApiKeyErrorKind::RandomFailure => {
write!(f, "api_key: CSPRNG failed during key generation")
}
ApiKeyErrorKind::InvalidFormat => {
write!(
f,
"api_key: key does not match \"esw_<prefix>_<secret>\" format"
)
}
ApiKeyErrorKind::InvalidPrefix => {
write!(
f,
"api_key: prefix segment is invalid (expected 8 hex characters)"
)
}
ApiKeyErrorKind::InvalidSecret => {
write!(
f,
"api_key: secret segment is invalid (expected 64 hex characters)"
)
}
}
}
}
impl std::error::Error for ApiKeyError {}
pub struct ApiKey {
prefix: String,
secret: Zeroizing<String>,
full_key: Zeroizing<String>,
}
impl ApiKey {
pub fn generate() -> Result<(Self, ApiKeyHash), ApiKeyError> {
let mut prefix_bytes = [0u8; PREFIX_BYTE_LEN];
let mut secret_bytes = [0u8; SECRET_BYTE_LEN];
fill_random(&mut prefix_bytes)
.map_err(|_| ApiKeyError::new(ApiKeyErrorKind::RandomFailure))?;
fill_random(&mut secret_bytes)
.map_err(|_| ApiKeyError::new(ApiKeyErrorKind::RandomFailure))?;
let prefix = hex_encode(&prefix_bytes);
let secret_hex = hex_encode(&secret_bytes);
let secret_hash = Sha256::digest(secret_bytes.as_slice());
crate::crypto::zeroize::zeroize(&mut secret_bytes);
let full_key = format!("{KEY_PREFIX}_{prefix}_{secret_hex}");
let api_key = Self {
prefix: prefix.clone(),
secret: Zeroizing::new(secret_hex),
full_key: Zeroizing::new(full_key),
};
let hash = ApiKeyHash {
prefix,
secret_hash,
};
info!(prefix = %api_key.prefix, "api_key: generated");
Ok((api_key, hash))
}
pub fn parse(key_str: &str) -> Result<Self, ApiKeyError> {
let parts: Vec<&str> = key_str.splitn(3, '_').collect();
if parts.len() != 3 || parts[0] != KEY_PREFIX {
return Err(ApiKeyError::new(ApiKeyErrorKind::InvalidFormat));
}
let prefix_str = parts[1];
let secret_str = parts[2];
if prefix_str.len() != PREFIX_BYTE_LEN * 2 {
return Err(ApiKeyError::new(ApiKeyErrorKind::InvalidPrefix));
}
hex_decode(prefix_str).map_err(|_| ApiKeyError::new(ApiKeyErrorKind::InvalidPrefix))?;
if secret_str.len() != SECRET_BYTE_LEN * 2 {
return Err(ApiKeyError::new(ApiKeyErrorKind::InvalidSecret));
}
hex_decode(secret_str).map_err(|_| ApiKeyError::new(ApiKeyErrorKind::InvalidSecret))?;
Ok(Self {
prefix: prefix_str.to_owned(),
secret: Zeroizing::new(secret_str.to_owned()),
full_key: Zeroizing::new(key_str.to_owned()),
})
}
#[must_use]
pub fn prefix(&self) -> &str {
&self.prefix
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.full_key
}
#[must_use]
pub fn verify(&self, hash: &ApiKeyHash) -> bool {
if self.prefix != hash.prefix {
warn!(prefix = %self.prefix, "api_key: verification failed (prefix mismatch)");
return false;
}
let Ok(secret_bytes) = hex_decode(&self.secret).map(Zeroizing::new) else {
warn!(prefix = %self.prefix, "api_key: verification failed");
return false;
};
let computed_hash = Sha256::digest(&secret_bytes);
let matches = constant_time_eq(&computed_hash, &hash.secret_hash);
if matches {
debug!(prefix = %self.prefix, "api_key: verified");
} else {
warn!(prefix = %self.prefix, "api_key: verification failed");
}
matches
}
}
impl fmt::Debug for ApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKey")
.field("prefix", &self.prefix)
.field("secret", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl fmt::Display for ApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "esw_{}_[REDACTED]", self.prefix)
}
}
#[derive(Clone)]
pub struct ApiKeyHash {
prefix: String,
secret_hash: [u8; 32],
}
impl ApiKeyHash {
#[must_use]
pub fn prefix(&self) -> &str {
&self.prefix
}
#[must_use]
pub fn secret_hash(&self) -> &[u8; 32] {
&self.secret_hash
}
#[must_use]
pub fn from_parts(prefix: String, secret_hash: [u8; 32]) -> Self {
Self {
prefix,
secret_hash,
}
}
}
impl fmt::Debug for ApiKeyHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKeyHash")
.field("prefix", &self.prefix)
.field("secret_hash", &"[HASH]")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_and_verify() {
let (key, hash) = ApiKey::generate().unwrap();
assert!(
key.verify(&hash),
"generated key should verify against its own hash",
);
}
#[test]
fn generate_produces_correct_format() {
let (key, _hash) = ApiKey::generate().unwrap();
let full = key.as_str();
assert!(full.starts_with("esw_"), "key should start with 'esw_'");
let parts: Vec<&str> = full.splitn(3, '_').collect();
assert_eq!(
parts.len(),
3,
"key should have 3 underscore-delimited parts"
);
assert_eq!(parts[0], "esw");
assert_eq!(parts[1].len(), 8, "prefix should be 8 hex chars");
assert_eq!(parts[2].len(), 64, "secret should be 64 hex chars");
}
#[test]
fn generate_keys_are_unique() {
let (key1, _) = ApiKey::generate().unwrap();
let (key2, _) = ApiKey::generate().unwrap();
assert_ne!(
key1.as_str(),
key2.as_str(),
"two generated keys should differ",
);
}
#[test]
fn parse_round_trip() {
let (key, hash) = ApiKey::generate().unwrap();
let key_str = key.as_str().to_owned();
let parsed = ApiKey::parse(&key_str).unwrap();
assert_eq!(parsed.prefix(), key.prefix());
assert!(
parsed.verify(&hash),
"parsed key should verify against the original hash",
);
}
#[test]
fn parse_valid_key() {
let key_str =
"esw_deadbeef_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let key = ApiKey::parse(key_str).unwrap();
assert_eq!(key.prefix(), "deadbeef");
assert_eq!(key.as_str(), key_str);
}
#[test]
fn parse_rejects_missing_esw_prefix() {
let err = ApiKey::parse("xxx_deadbeef_aabb").unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidFormat);
}
#[test]
fn parse_rejects_too_few_segments() {
let err = ApiKey::parse("esw_onlyone").unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidFormat);
}
#[test]
fn parse_rejects_wrong_prefix_length() {
let err = ApiKey::parse(
"esw_abcdef_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidPrefix);
}
#[test]
fn parse_rejects_wrong_secret_length() {
let err = ApiKey::parse("esw_deadbeef_aabbccdd").unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidSecret);
}
#[test]
fn parse_rejects_non_hex_prefix() {
let err = ApiKey::parse(
"esw_zzzzzzzz_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidPrefix);
}
#[test]
fn parse_rejects_non_hex_secret() {
let err = ApiKey::parse(
"esw_deadbeef_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
)
.unwrap_err();
assert_eq!(err.kind, ApiKeyErrorKind::InvalidSecret);
}
#[test]
fn wrong_key_fails_verify() {
let (_key1, hash1) = ApiKey::generate().unwrap();
let (key2, _hash2) = ApiKey::generate().unwrap();
assert!(
!key2.verify(&hash1),
"a different key should not verify against another key's hash",
);
}
#[test]
fn tampered_secret_fails_verify() {
let (key, hash) = ApiKey::generate().unwrap();
let key_str = key.as_str();
let mut chars: Vec<char> = key_str.chars().collect();
let idx = 13;
chars[idx] = if chars[idx] == 'a' { 'b' } else { 'a' };
let tampered: String = chars.into_iter().collect();
let tampered_key = ApiKey::parse(&tampered).unwrap();
assert!(
!tampered_key.verify(&hash),
"tampered key should fail verification",
);
}
#[test]
fn prefix_is_visible() {
let (key, hash) = ApiKey::generate().unwrap();
assert_eq!(key.prefix().len(), 8);
assert!(key.prefix().chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(key.prefix(), hash.prefix());
}
#[test]
fn debug_redacts_secret() {
let (key, _) = ApiKey::generate().unwrap();
let debug = format!("{key:?}");
assert!(
debug.contains("[REDACTED]"),
"Debug should contain [REDACTED]: {debug}",
);
let secret_start = &key.as_str()[13..29]; assert!(
!debug.contains(secret_start),
"Debug should not contain secret material: {debug}",
);
}
#[test]
fn display_shows_redacted_form() {
let (key, _) = ApiKey::generate().unwrap();
let display = format!("{key}");
assert!(
display.starts_with("esw_"),
"Display should start with esw_"
);
assert!(
display.ends_with("_[REDACTED]"),
"Display should end with _[REDACTED]: {display}",
);
assert!(
display.contains(key.prefix()),
"Display should contain the prefix",
);
}
#[test]
fn api_key_hash_debug_redacts_hash() {
let (_, hash) = ApiKey::generate().unwrap();
let debug = format!("{hash:?}");
assert!(
debug.contains("[HASH]"),
"ApiKeyHash Debug should contain [HASH]: {debug}",
);
}
#[test]
fn error_display_messages() {
let cases = [
(
ApiKeyError::new(ApiKeyErrorKind::RandomFailure),
"api_key: CSPRNG failed during key generation",
),
(
ApiKeyError::new(ApiKeyErrorKind::InvalidFormat),
"api_key: key does not match \"esw_<prefix>_<secret>\" format",
),
(
ApiKeyError::new(ApiKeyErrorKind::InvalidPrefix),
"api_key: prefix segment is invalid (expected 8 hex characters)",
),
(
ApiKeyError::new(ApiKeyErrorKind::InvalidSecret),
"api_key: secret segment is invalid (expected 64 hex characters)",
),
];
for (err, expected) in &cases {
assert_eq!(err.to_string(), *expected);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(ApiKeyError::new(ApiKeyErrorKind::InvalidFormat));
let _ = err.to_string();
}
}