1use 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
28pub const ENV_JWT_SECRET: &str = "ZLAYER_JWT_SECRET";
30
31const GENERATED_SECRET_BYTES: usize = 64;
36
37#[derive(Debug, Clone)]
42pub struct JwtSecretManager {
43 base_dir: PathBuf,
44}
45
46impl JwtSecretManager {
47 #[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 fn secret_file_path(&self, deployment: &str) -> PathBuf {
57 self.base_dir.join(format!("jwt_secret_{deployment}.key"))
58 }
59
60 #[must_use]
68 pub fn cluster_secret_file_path(&self) -> PathBuf {
69 self.base_dir.join("cluster_jwt_secret")
70 }
71
72 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 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 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 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 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}