Skip to main content

rustlavel_auth/
key.rs

1//! The application key, and the sub-keys derived from it.
2//!
3//! One secret is configured (`APP_KEY`) and everything else is derived from it,
4//! so an operator has exactly one thing to rotate. Encryption, session-cookie
5//! signing and URL signing each get their *own* key, derived with a distinct
6//! label: a signature produced for one purpose must never verify for another,
7//! or a signed download link becomes a forged session cookie.
8
9use crate::base64;
10use hmac::{Hmac, Mac};
11use rustlavel_core::{Config, Error, Result};
12use sha2::Sha256;
13
14type HmacSha256 = Hmac<Sha256>;
15
16/// How many bytes of key material the framework requires.
17pub const KEY_LENGTH: usize = 32;
18
19/// The 32-byte secret behind every signature and every ciphertext.
20///
21/// `Debug` is implemented by hand and prints nothing useful, so a key cannot
22/// leak into a log line or the development error page by accident.
23#[derive(Clone)]
24pub struct AppKey([u8; KEY_LENGTH]);
25
26impl AppKey {
27    /// Wrap raw key material.
28    pub fn from_bytes(bytes: [u8; KEY_LENGTH]) -> Self {
29        AppKey(bytes)
30    }
31
32    /// Parse a configured key, with or without Laravel's `base64:` prefix.
33    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    /// Read `app.key` out of the configuration tree.
59    pub fn from_config(config: &Config) -> Result<Self> {
60        AppKey::from_base64(&config.string("app.key", ""))
61    }
62
63    /// A fresh key, formatted the way it belongs in `.env`.
64    ///
65    /// The `base64:` prefix is kept so the value is self-describing: an
66    /// operator can tell at a glance that the key is encoded, not a passphrase.
67    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    /// Derive a purpose-specific sub-key.
76    ///
77    /// HMAC-SHA256 with the application key as the MAC key and the label as the
78    /// message: a standard one-step KDF, and the reason a `signed-url`
79    /// signature can never be replayed as a `session-cookie` signature.
80    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        // Derivation is deterministic, or nothing signed yesterday would verify.
136        assert_eq!(key.derive("signed-url"), key.derive("signed-url"));
137        // And a sub-key is never the application key itself.
138        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}