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    /// Returns the path to the cluster-wide JWT secret file.
61    ///
62    /// This file holds the *literal* signing string the whole cluster shares
63    /// (propagated from the leader during a node join), as opposed to the
64    /// per-node random `jwt_secret_{deployment}.key`. When present, daemon
65    /// startup prefers it over `get_or_create`, so a session cookie minted on
66    /// any node validates cluster-wide.
67    #[must_use]
68    pub fn cluster_secret_file_path(&self) -> PathBuf {
69        self.base_dir.join("cluster_jwt_secret")
70    }
71
72    /// Load the cluster-wide JWT secret if one was propagated to this node by
73    /// the leader during a join.
74    ///
75    /// Returns `Ok(None)` when the file is absent (the standalone / never-joined
76    /// case) so the caller can fall back to [`Self::get_or_create`]. The file
77    /// holds the secret verbatim (UTF-8, trailing newline tolerated).
78    ///
79    /// # Errors
80    ///
81    /// Returns [`SecretsError::Encryption`] if the file exists but cannot be
82    /// read or is empty.
83    pub fn load_cluster_secret(&self) -> Result<Option<SecretString>> {
84        let path = self.cluster_secret_file_path();
85        if !path.exists() {
86            return Ok(None);
87        }
88        let raw = fs::read_to_string(&path).map_err(|e| {
89            SecretsError::Encryption(format!(
90                "Failed to read cluster JWT secret file {}: {e}",
91                path.display()
92            ))
93        })?;
94        let trimmed = raw.trim_end_matches(['\n', '\r']);
95        if trimmed.is_empty() {
96            return Err(SecretsError::Encryption(format!(
97                "cluster JWT secret file {} is empty",
98                path.display()
99            )));
100        }
101        debug!("Loaded cluster JWT secret from {}", path.display());
102        Ok(Some(SecretString::from(trimmed.to_string())))
103    }
104
105    /// Persist the cluster-wide JWT secret propagated by the leader, writing
106    /// the literal signing string to [`Self::cluster_secret_file_path`] with
107    /// mode `0600` on Unix.
108    ///
109    /// Idempotent in effect: an existing file is overwritten with the
110    /// leader-supplied value (the leader is the source of truth).
111    ///
112    /// # Errors
113    ///
114    /// Returns [`SecretsError::Encryption`] if `secret` is empty or the file
115    /// cannot be written.
116    pub fn import_cluster_secret(&self, secret: &str) -> Result<()> {
117        if secret.is_empty() {
118            return Err(SecretsError::Encryption(
119                "refusing to persist empty cluster JWT secret".to_string(),
120            ));
121        }
122        let path = self.cluster_secret_file_path();
123        if let Some(parent) = path.parent() {
124            fs::create_dir_all(parent).map_err(|e| {
125                SecretsError::Encryption(format!(
126                    "Failed to create cluster JWT secret directory {}: {e}",
127                    parent.display()
128                ))
129            })?;
130        }
131        fs::write(&path, secret.as_bytes()).map_err(|e| {
132            SecretsError::Encryption(format!(
133                "Failed to write cluster JWT secret file {}: {e}",
134                path.display()
135            ))
136        })?;
137
138        #[cfg(unix)]
139        {
140            use std::os::unix::fs::PermissionsExt;
141            let perms = fs::Permissions::from_mode(0o600);
142            if let Err(e) = fs::set_permissions(&path, perms) {
143                warn!(
144                    "Failed to set permissions on cluster JWT secret file {}: {e}",
145                    path.display()
146                );
147            }
148        }
149
150        info!("Persisted cluster JWT secret at {}", path.display());
151        Ok(())
152    }
153
154    /// Resolves the JWT secret, generating + persisting a fresh one when
155    /// neither the env var nor a saved file is present.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`SecretsError::Encryption`] if the file system cannot be
160    /// read or written, or if the loaded file is empty.
161    pub fn get_or_create(&self, deployment: &str) -> Result<SecretString> {
162        if let Ok(secret) = std::env::var(ENV_JWT_SECRET) {
163            if secret.is_empty() {
164                return Err(SecretsError::Encryption(format!(
165                    "{ENV_JWT_SECRET} is set but empty"
166                )));
167            }
168            debug!("Using JWT secret from {ENV_JWT_SECRET} environment variable");
169            return Ok(SecretString::from(secret));
170        }
171
172        let path = self.secret_file_path(deployment);
173        if path.exists() {
174            debug!("Loading JWT secret from file: {}", path.display());
175            return Self::load_from_file(&path);
176        }
177
178        info!(
179            "Generating new JWT secret for deployment '{}' at {}",
180            deployment,
181            path.display()
182        );
183        Self::generate_and_save(&path)
184    }
185
186    fn load_from_file(path: &Path) -> Result<SecretString> {
187        let bytes = fs::read(path).map_err(|e| {
188            SecretsError::Encryption(format!(
189                "Failed to read JWT secret file {}: {e}",
190                path.display()
191            ))
192        })?;
193        if bytes.is_empty() {
194            return Err(SecretsError::Encryption(format!(
195                "JWT secret file {} is empty",
196                path.display()
197            )));
198        }
199        Ok(SecretString::from(hex::encode(bytes)))
200    }
201
202    fn generate_and_save(path: &Path) -> Result<SecretString> {
203        if let Some(parent) = path.parent() {
204            fs::create_dir_all(parent).map_err(|e| {
205                SecretsError::Encryption(format!(
206                    "Failed to create JWT secret directory {}: {e}",
207                    parent.display()
208                ))
209            })?;
210        }
211
212        let mut bytes = vec![0u8; GENERATED_SECRET_BYTES];
213        OsRng
214            .try_fill_bytes(&mut bytes)
215            .map_err(|e| SecretsError::Encryption(format!("OS RNG failed: {e}")))?;
216
217        fs::write(path, &bytes).map_err(|e| {
218            SecretsError::Encryption(format!(
219                "Failed to write JWT secret file {}: {e}",
220                path.display()
221            ))
222        })?;
223
224        #[cfg(unix)]
225        {
226            use std::os::unix::fs::PermissionsExt;
227            let perms = fs::Permissions::from_mode(0o600);
228            if let Err(e) = fs::set_permissions(path, perms) {
229                warn!(
230                    "Failed to set permissions on JWT secret file {}: {e}",
231                    path.display()
232                );
233            }
234        }
235
236        info!("Created new JWT secret at {}", path.display());
237        Ok(SecretString::from(hex::encode(&bytes)))
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use secrecy::ExposeSecret;
245    use serial_test::serial;
246    use std::env;
247    use tempfile::TempDir;
248
249    struct EnvGuard;
250
251    impl EnvGuard {
252        fn new() -> Self {
253            env::remove_var(ENV_JWT_SECRET);
254            Self
255        }
256    }
257
258    impl Drop for EnvGuard {
259        fn drop(&mut self) {
260            env::remove_var(ENV_JWT_SECRET);
261        }
262    }
263
264    fn setup() -> (JwtSecretManager, TempDir) {
265        let dir = TempDir::new().unwrap();
266        (JwtSecretManager::with_base_dir(dir.path()), dir)
267    }
268
269    #[test]
270    #[serial]
271    fn generates_and_persists() {
272        let _g = EnvGuard::new();
273        let (mgr, _t) = setup();
274        let s1 = mgr.get_or_create("dev").unwrap();
275        let path = mgr.secret_file_path("dev");
276        assert!(path.exists(), "secret file should be persisted");
277        // Hex of 64 bytes is 128 chars.
278        assert_eq!(s1.expose_secret().len(), GENERATED_SECRET_BYTES * 2);
279    }
280
281    #[test]
282    #[serial]
283    fn stable_across_calls() {
284        let _g = EnvGuard::new();
285        let (mgr, _t) = setup();
286        let s1 = mgr.get_or_create("dev").unwrap();
287        let s2 = mgr.get_or_create("dev").unwrap();
288        assert_eq!(s1.expose_secret(), s2.expose_secret());
289    }
290
291    #[test]
292    #[serial]
293    fn different_deployments_get_different_secrets() {
294        let _g = EnvGuard::new();
295        let (mgr, _t) = setup();
296        let a = mgr.get_or_create("a").unwrap();
297        let b = mgr.get_or_create("b").unwrap();
298        assert_ne!(a.expose_secret(), b.expose_secret());
299    }
300
301    #[test]
302    #[serial]
303    fn env_overrides_file() {
304        let _g = EnvGuard::new();
305        let (mgr, _t) = setup();
306        // Pre-create a file-backed secret.
307        let _file_secret = mgr.get_or_create("dev").unwrap();
308        env::set_var(ENV_JWT_SECRET, "operator-supplied");
309        let env_secret = mgr.get_or_create("dev").unwrap();
310        assert_eq!(env_secret.expose_secret(), "operator-supplied");
311    }
312
313    #[test]
314    #[serial]
315    fn empty_env_is_rejected() {
316        let _g = EnvGuard::new();
317        let (mgr, _t) = setup();
318        env::set_var(ENV_JWT_SECRET, "");
319        let result = mgr.get_or_create("dev");
320        assert!(result.is_err());
321    }
322
323    #[test]
324    #[serial]
325    fn cluster_secret_absent_returns_none() {
326        let _g = EnvGuard::new();
327        let (mgr, _t) = setup();
328        assert!(
329            mgr.load_cluster_secret().unwrap().is_none(),
330            "no cluster secret file means standalone -> None"
331        );
332    }
333
334    #[test]
335    #[serial]
336    fn cluster_secret_import_then_load_roundtrips() {
337        let _g = EnvGuard::new();
338        let (mgr, _t) = setup();
339        mgr.import_cluster_secret("cluster-shared-signing-key")
340            .unwrap();
341        let loaded = mgr.load_cluster_secret().unwrap().expect("present");
342        assert_eq!(loaded.expose_secret(), "cluster-shared-signing-key");
343    }
344
345    #[test]
346    #[serial]
347    fn cluster_secret_tolerates_trailing_newline() {
348        let _g = EnvGuard::new();
349        let (mgr, _t) = setup();
350        fs::write(mgr.cluster_secret_file_path(), b"key-with-newline\n").unwrap();
351        let loaded = mgr.load_cluster_secret().unwrap().expect("present");
352        assert_eq!(loaded.expose_secret(), "key-with-newline");
353    }
354
355    #[test]
356    #[serial]
357    fn cluster_secret_rejects_empty_import() {
358        let _g = EnvGuard::new();
359        let (mgr, _t) = setup();
360        assert!(mgr.import_cluster_secret("").is_err());
361    }
362
363    #[cfg(unix)]
364    #[test]
365    #[serial]
366    fn cluster_secret_file_has_restrictive_perms() {
367        use std::os::unix::fs::PermissionsExt;
368        let _g = EnvGuard::new();
369        let (mgr, _t) = setup();
370        mgr.import_cluster_secret("k").unwrap();
371        let mode = fs::metadata(mgr.cluster_secret_file_path())
372            .unwrap()
373            .permissions()
374            .mode()
375            & 0o777;
376        assert_eq!(mode, 0o600);
377    }
378
379    #[cfg(unix)]
380    #[test]
381    #[serial]
382    fn file_has_restrictive_perms() {
383        use std::os::unix::fs::PermissionsExt;
384        let _g = EnvGuard::new();
385        let (mgr, _t) = setup();
386        mgr.get_or_create("dev").unwrap();
387        let path = mgr.secret_file_path("dev");
388        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
389        assert_eq!(mode, 0o600);
390    }
391}