use crate::base64;
use hmac::{Hmac, Mac};
use rustlavel_core::{Config, Error, Result};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const KEY_LENGTH: usize = 32;
#[derive(Clone)]
pub struct AppKey([u8; KEY_LENGTH]);
impl AppKey {
pub fn from_bytes(bytes: [u8; KEY_LENGTH]) -> Self {
AppKey(bytes)
}
pub fn from_base64(value: &str) -> Result<Self> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(Error::msg(
"APP_KEY is empty. Run `rustlavel key:generate` and put the result in .env — \
without it sessions, cookies and signed URLs cannot be secured.",
));
}
let encoded = trimmed.strip_prefix("base64:").unwrap_or(trimmed);
let decoded = base64::decode(encoded).ok_or_else(|| {
Error::msg("APP_KEY is not valid base64. Regenerate it with `rustlavel key:generate`.")
})?;
let bytes: [u8; KEY_LENGTH] = decoded.as_slice().try_into().map_err(|_| {
Error::msg(format!(
"APP_KEY decodes to {} bytes, but {KEY_LENGTH} are required. Regenerate it with \
`rustlavel key:generate`.",
decoded.len()
))
})?;
Ok(AppKey(bytes))
}
pub fn from_config(config: &Config) -> Result<Self> {
AppKey::from_base64(&config.string("app.key", ""))
}
pub fn generate() -> String {
format!("base64:{}", base64::encode(&crate::random::bytes(KEY_LENGTH)))
}
pub fn as_bytes(&self) -> &[u8; KEY_LENGTH] {
&self.0
}
pub fn derive(&self, purpose: &str) -> [u8; KEY_LENGTH] {
let mut mac = HmacSha256::new_from_slice(&self.0).expect("HMAC accepts any key length");
mac.update(b"rustlavel:");
mac.update(purpose.as_bytes());
mac.finalize().into_bytes().into()
}
}
impl std::fmt::Debug for AppKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AppKey(<redacted>)")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_generated_key_with_or_without_the_prefix() {
let generated = AppKey::generate();
assert!(generated.starts_with("base64:"));
let with_prefix = AppKey::from_base64(&generated).unwrap();
let without_prefix =
AppKey::from_base64(generated.strip_prefix("base64:").unwrap()).unwrap();
assert_eq!(with_prefix.as_bytes(), without_prefix.as_bytes());
}
#[test]
fn rejects_keys_that_are_missing_malformed_or_the_wrong_length() {
assert!(AppKey::from_base64("").is_err());
assert!(AppKey::from_base64("base64:not valid!").is_err());
let short = base64::encode(&[0u8; 16]);
let error = AppKey::from_base64(&short).unwrap_err().to_string();
assert!(error.contains("16 bytes"), "message should name the actual length: {error}");
}
#[test]
fn reads_the_key_from_configuration() {
let config = Config::new();
config.set("app.key", AppKey::generate());
assert!(AppKey::from_config(&config).is_ok());
assert!(AppKey::from_config(&Config::new()).is_err());
}
#[test]
fn different_purposes_derive_different_sub_keys() {
let key = AppKey::from_bytes([7u8; KEY_LENGTH]);
assert_ne!(key.derive("encryption"), key.derive("signed-url"));
assert_ne!(key.derive("signed-url"), key.derive("session-cookie"));
assert_eq!(key.derive("signed-url"), key.derive("signed-url"));
assert_ne!(&key.derive("encryption"), key.as_bytes());
}
#[test]
fn debug_output_never_contains_the_key() {
let key = AppKey::from_bytes([0xab; KEY_LENGTH]);
assert_eq!(format!("{key:?}"), "AppKey(<redacted>)");
}
}