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 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 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 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}