1use crate::base64;
10use hmac::{Hmac, Mac};
11use rustlavel_core::{Config, Error, Result};
12use sha2::Sha256;
13
14type HmacSha256 = Hmac<Sha256>;
15
16pub const KEY_LENGTH: usize = 32;
18
19#[derive(Clone)]
24pub struct AppKey([u8; KEY_LENGTH]);
25
26impl AppKey {
27 pub fn from_bytes(bytes: [u8; KEY_LENGTH]) -> Self {
29 AppKey(bytes)
30 }
31
32 pub fn from_base64(value: &str) -> Result<Self> {
34 let trimmed = value.trim();
35 if trimmed.is_empty() {
36 return Err(Error::msg(
37 "APP_KEY is empty. Run `rustlavel key:generate` and put the result in .env — \
38 without it sessions, cookies and signed URLs cannot be secured.",
39 ));
40 }
41
42 let encoded = trimmed.strip_prefix("base64:").unwrap_or(trimmed);
43 let decoded = base64::decode(encoded).ok_or_else(|| {
44 Error::msg("APP_KEY is not valid base64. Regenerate it with `rustlavel key:generate`.")
45 })?;
46
47 let bytes: [u8; KEY_LENGTH] = decoded.as_slice().try_into().map_err(|_| {
48 Error::msg(format!(
49 "APP_KEY decodes to {} bytes, but {KEY_LENGTH} are required. Regenerate it with \
50 `rustlavel key:generate`.",
51 decoded.len()
52 ))
53 })?;
54
55 Ok(AppKey(bytes))
56 }
57
58 pub fn from_config(config: &Config) -> Result<Self> {
60 AppKey::from_base64(&config.string("app.key", ""))
61 }
62
63 pub fn generate() -> String {
68 format!("base64:{}", base64::encode(&crate::random::bytes(KEY_LENGTH)))
69 }
70
71 pub fn as_bytes(&self) -> &[u8; KEY_LENGTH] {
72 &self.0
73 }
74
75 pub fn derive(&self, purpose: &str) -> [u8; KEY_LENGTH] {
81 let mut mac = HmacSha256::new_from_slice(&self.0).expect("HMAC accepts any key length");
82 mac.update(b"rustlavel:");
83 mac.update(purpose.as_bytes());
84 mac.finalize().into_bytes().into()
85 }
86}
87
88impl std::fmt::Debug for AppKey {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.write_str("AppKey(<redacted>)")
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn accepts_a_generated_key_with_or_without_the_prefix() {
100 let generated = AppKey::generate();
101 assert!(generated.starts_with("base64:"));
102
103 let with_prefix = AppKey::from_base64(&generated).unwrap();
104 let without_prefix =
105 AppKey::from_base64(generated.strip_prefix("base64:").unwrap()).unwrap();
106
107 assert_eq!(with_prefix.as_bytes(), without_prefix.as_bytes());
108 }
109
110 #[test]
111 fn rejects_keys_that_are_missing_malformed_or_the_wrong_length() {
112 assert!(AppKey::from_base64("").is_err());
113 assert!(AppKey::from_base64("base64:not valid!").is_err());
114
115 let short = base64::encode(&[0u8; 16]);
116 let error = AppKey::from_base64(&short).unwrap_err().to_string();
117 assert!(error.contains("16 bytes"), "message should name the actual length: {error}");
118 }
119
120 #[test]
121 fn reads_the_key_from_configuration() {
122 let config = Config::new();
123 config.set("app.key", AppKey::generate());
124
125 assert!(AppKey::from_config(&config).is_ok());
126 assert!(AppKey::from_config(&Config::new()).is_err());
127 }
128
129 #[test]
130 fn different_purposes_derive_different_sub_keys() {
131 let key = AppKey::from_bytes([7u8; KEY_LENGTH]);
132
133 assert_ne!(key.derive("encryption"), key.derive("signed-url"));
134 assert_ne!(key.derive("signed-url"), key.derive("session-cookie"));
135 assert_eq!(key.derive("signed-url"), key.derive("signed-url"));
137 assert_ne!(&key.derive("encryption"), key.as_bytes());
139 }
140
141 #[test]
142 fn debug_output_never_contains_the_key() {
143 let key = AppKey::from_bytes([0xab; KEY_LENGTH]);
144 assert_eq!(format!("{key:?}"), "AppKey(<redacted>)");
145 }
146}