Skip to main content

zlayer_secrets/
jwt.rs

1//! JWT signing-key management for the `ZLayer` API daemon.
2//!
3//! Mirrors [`KeyManager`](crate::KeyManager) for the JWT secret used by
4//! `zlayer-api` to sign session cookies. Without persistence, every daemon
5//! restart that omits `ZLAYER_JWT_SECRET` invalidates every previously
6//! issued session cookie — users get logged out on every restart.
7//!
8//! ## Resolution Priority
9//!
10//! 1. `ZLAYER_JWT_SECRET` environment variable — operator opt-out, returned
11//!    verbatim and never persisted.
12//! 2. File at `{base_dir}/jwt_secret_{deployment}.key` — loaded as bytes.
13//! 3. Auto-generated: 64 random bytes (sufficient for HMAC-SHA256), saved
14//!    to the same file with mode `0600` for future runs.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use rand::rngs::OsRng;
20use rand::TryRngCore;
21use secrecy::SecretString;
22#[cfg(unix)]
23use tracing::warn;
24use tracing::{debug, info};
25
26use crate::{Result, SecretsError};
27
28/// Environment variable name for the operator-supplied JWT secret.
29pub const ENV_JWT_SECRET: &str = "ZLAYER_JWT_SECRET";
30
31/// Length in bytes of an auto-generated JWT secret. 64 is the natural ceiling
32/// for HMAC-SHA256 (HS256): keys longer than the hash block size get hashed
33/// before use, so 64 random bytes is the upper end of what the signer
34/// actually consumes.
35const GENERATED_SECRET_BYTES: usize = 64;
36
37/// Manages the API daemon's JWT signing secret.
38///
39/// Use [`JwtSecretManager::with_base_dir`] alongside [`KeyManager`](crate::KeyManager)
40/// during daemon startup so both keys live in the same directory.
41#[derive(Debug, Clone)]
42pub struct JwtSecretManager {
43    base_dir: PathBuf,
44}
45
46impl JwtSecretManager {
47    /// Creates a manager rooted at `base_dir`.
48    #[must_use]
49    pub fn with_base_dir(base_dir: impl AsRef<Path>) -> Self {
50        Self {
51            base_dir: base_dir.as_ref().to_path_buf(),
52        }
53    }
54
55    /// Returns the path to the JWT secret file for `deployment`.
56    fn secret_file_path(&self, deployment: &str) -> PathBuf {
57        self.base_dir.join(format!("jwt_secret_{deployment}.key"))
58    }
59
60    /// Resolves the JWT secret, generating + persisting a fresh one when
61    /// neither the env var nor a saved file is present.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`SecretsError::Encryption`] if the file system cannot be
66    /// read or written, or if the loaded file is empty.
67    pub fn get_or_create(&self, deployment: &str) -> Result<SecretString> {
68        if let Ok(secret) = std::env::var(ENV_JWT_SECRET) {
69            if secret.is_empty() {
70                return Err(SecretsError::Encryption(format!(
71                    "{ENV_JWT_SECRET} is set but empty"
72                )));
73            }
74            debug!("Using JWT secret from {ENV_JWT_SECRET} environment variable");
75            return Ok(SecretString::from(secret));
76        }
77
78        let path = self.secret_file_path(deployment);
79        if path.exists() {
80            debug!("Loading JWT secret from file: {}", path.display());
81            return Self::load_from_file(&path);
82        }
83
84        info!(
85            "Generating new JWT secret for deployment '{}' at {}",
86            deployment,
87            path.display()
88        );
89        Self::generate_and_save(&path)
90    }
91
92    fn load_from_file(path: &Path) -> Result<SecretString> {
93        let bytes = fs::read(path).map_err(|e| {
94            SecretsError::Encryption(format!(
95                "Failed to read JWT secret file {}: {e}",
96                path.display()
97            ))
98        })?;
99        if bytes.is_empty() {
100            return Err(SecretsError::Encryption(format!(
101                "JWT secret file {} is empty",
102                path.display()
103            )));
104        }
105        Ok(SecretString::from(hex::encode(bytes)))
106    }
107
108    fn generate_and_save(path: &Path) -> Result<SecretString> {
109        if let Some(parent) = path.parent() {
110            fs::create_dir_all(parent).map_err(|e| {
111                SecretsError::Encryption(format!(
112                    "Failed to create JWT secret directory {}: {e}",
113                    parent.display()
114                ))
115            })?;
116        }
117
118        let mut bytes = vec![0u8; GENERATED_SECRET_BYTES];
119        OsRng
120            .try_fill_bytes(&mut bytes)
121            .map_err(|e| SecretsError::Encryption(format!("OS RNG failed: {e}")))?;
122
123        fs::write(path, &bytes).map_err(|e| {
124            SecretsError::Encryption(format!(
125                "Failed to write JWT secret file {}: {e}",
126                path.display()
127            ))
128        })?;
129
130        #[cfg(unix)]
131        {
132            use std::os::unix::fs::PermissionsExt;
133            let perms = fs::Permissions::from_mode(0o600);
134            if let Err(e) = fs::set_permissions(path, perms) {
135                warn!(
136                    "Failed to set permissions on JWT secret file {}: {e}",
137                    path.display()
138                );
139            }
140        }
141
142        info!("Created new JWT secret at {}", path.display());
143        Ok(SecretString::from(hex::encode(&bytes)))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use secrecy::ExposeSecret;
151    use serial_test::serial;
152    use std::env;
153    use tempfile::TempDir;
154
155    struct EnvGuard;
156
157    impl EnvGuard {
158        fn new() -> Self {
159            env::remove_var(ENV_JWT_SECRET);
160            Self
161        }
162    }
163
164    impl Drop for EnvGuard {
165        fn drop(&mut self) {
166            env::remove_var(ENV_JWT_SECRET);
167        }
168    }
169
170    fn setup() -> (JwtSecretManager, TempDir) {
171        let dir = TempDir::new().unwrap();
172        (JwtSecretManager::with_base_dir(dir.path()), dir)
173    }
174
175    #[test]
176    #[serial]
177    fn generates_and_persists() {
178        let _g = EnvGuard::new();
179        let (mgr, _t) = setup();
180        let s1 = mgr.get_or_create("dev").unwrap();
181        let path = mgr.secret_file_path("dev");
182        assert!(path.exists(), "secret file should be persisted");
183        // Hex of 64 bytes is 128 chars.
184        assert_eq!(s1.expose_secret().len(), GENERATED_SECRET_BYTES * 2);
185    }
186
187    #[test]
188    #[serial]
189    fn stable_across_calls() {
190        let _g = EnvGuard::new();
191        let (mgr, _t) = setup();
192        let s1 = mgr.get_or_create("dev").unwrap();
193        let s2 = mgr.get_or_create("dev").unwrap();
194        assert_eq!(s1.expose_secret(), s2.expose_secret());
195    }
196
197    #[test]
198    #[serial]
199    fn different_deployments_get_different_secrets() {
200        let _g = EnvGuard::new();
201        let (mgr, _t) = setup();
202        let a = mgr.get_or_create("a").unwrap();
203        let b = mgr.get_or_create("b").unwrap();
204        assert_ne!(a.expose_secret(), b.expose_secret());
205    }
206
207    #[test]
208    #[serial]
209    fn env_overrides_file() {
210        let _g = EnvGuard::new();
211        let (mgr, _t) = setup();
212        // Pre-create a file-backed secret.
213        let _file_secret = mgr.get_or_create("dev").unwrap();
214        env::set_var(ENV_JWT_SECRET, "operator-supplied");
215        let env_secret = mgr.get_or_create("dev").unwrap();
216        assert_eq!(env_secret.expose_secret(), "operator-supplied");
217    }
218
219    #[test]
220    #[serial]
221    fn empty_env_is_rejected() {
222        let _g = EnvGuard::new();
223        let (mgr, _t) = setup();
224        env::set_var(ENV_JWT_SECRET, "");
225        let result = mgr.get_or_create("dev");
226        assert!(result.is_err());
227    }
228
229    #[cfg(unix)]
230    #[test]
231    #[serial]
232    fn file_has_restrictive_perms() {
233        use std::os::unix::fs::PermissionsExt;
234        let _g = EnvGuard::new();
235        let (mgr, _t) = setup();
236        mgr.get_or_create("dev").unwrap();
237        let path = mgr.secret_file_path("dev");
238        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
239        assert_eq!(mode, 0o600);
240    }
241}