jacs_cli/
password_bootstrap.rs1use std::env;
2use std::error::Error;
3use std::path::Path;
4
5const PRIVATE_KEY_PASSWORD_ENV: &str = "JACS_PRIVATE_KEY_PASSWORD";
6const CLI_PASSWORD_FILE_ENV: &str = "JACS_PASSWORD_FILE";
7const DEFAULT_LEGACY_PASSWORD_FILE: &str = "./jacs_keys/.jacs_password";
8
9pub fn quickstart_password_bootstrap_help() -> &'static str {
10 "Password bootstrap options (prefer exactly one explicit source):
11 1) Direct env (recommended):
12 export JACS_PRIVATE_KEY_PASSWORD='your-strong-password'
13 2) Export from a secret file:
14 export JACS_PRIVATE_KEY_PASSWORD=\"$(cat /path/to/password)\"
15 3) CLI convenience (file path):
16 export JACS_PASSWORD_FILE=/path/to/password
17If both JACS_PRIVATE_KEY_PASSWORD and JACS_PASSWORD_FILE are set, CLI warns and uses JACS_PRIVATE_KEY_PASSWORD.
18If neither is set, CLI will try legacy ./jacs_keys/.jacs_password when present."
19}
20
21fn set_private_key_password_env(password: &str) {
22 unsafe {
24 env::set_var(PRIVATE_KEY_PASSWORD_ENV, password);
25 }
26}
27
28fn read_password_from_file(path: &Path, source_name: &str) -> Result<String, String> {
29 #[cfg(unix)]
30 {
31 use std::os::unix::fs::PermissionsExt;
32
33 let metadata = std::fs::metadata(path)
34 .map_err(|e| format!("Failed to read {} '{}': {}", source_name, path.display(), e))?;
35 let mode = metadata.permissions().mode() & 0o777;
36 if mode & 0o077 != 0 {
37 return Err(format!(
38 "{} '{}' has insecure permissions (mode {:04o}). \
39 File must not be group-readable or world-readable. \
40 Fix with: chmod 600 '{}'\n\n{}",
41 source_name,
42 path.display(),
43 mode,
44 path.display(),
45 quickstart_password_bootstrap_help()
46 ));
47 }
48 }
49
50 let raw = std::fs::read_to_string(path)
51 .map_err(|e| format!("Failed to read {} '{}': {}", source_name, path.display(), e))?;
52 let password = raw.trim_end_matches(['\n', '\r']);
53 if password.is_empty() {
54 return Err(format!(
55 "{} '{}' is empty. {}",
56 source_name,
57 path.display(),
58 quickstart_password_bootstrap_help()
59 ));
60 }
61 Ok(password.to_string())
62}
63
64fn get_non_empty_env_var(key: &str) -> Result<Option<String>, String> {
65 match env::var(key) {
66 Ok(value) => {
67 if value.trim().is_empty() {
68 Err(format!(
69 "{} is set but empty. {}",
70 key,
71 quickstart_password_bootstrap_help()
72 ))
73 } else {
74 Ok(Some(value))
75 }
76 }
77 Err(env::VarError::NotPresent) => Ok(None),
78 Err(env::VarError::NotUnicode(_)) => Err(format!(
79 "{} contains non-UTF-8 data. {}",
80 key,
81 quickstart_password_bootstrap_help()
82 )),
83 }
84}
85
86pub fn ensure_cli_private_key_password() -> Result<Option<String>, String> {
95 let env_password = get_non_empty_env_var(PRIVATE_KEY_PASSWORD_ENV)?;
96 let password_file = get_non_empty_env_var(CLI_PASSWORD_FILE_ENV)?;
97
98 if let Some(password) = env_password {
99 if password_file.is_some() {
100 eprintln!(
101 "Warning: both JACS_PRIVATE_KEY_PASSWORD and {} are set. \
102 Using JACS_PRIVATE_KEY_PASSWORD (highest priority).",
103 CLI_PASSWORD_FILE_ENV
104 );
105 }
106 set_private_key_password_env(&password);
107 return Ok(Some(password));
108 }
109
110 if let Some(path) = password_file {
111 let password = read_password_from_file(Path::new(path.trim()), CLI_PASSWORD_FILE_ENV)?;
112 set_private_key_password_env(&password);
113 return Ok(Some(password));
114 }
115
116 let legacy_path = Path::new(DEFAULT_LEGACY_PASSWORD_FILE);
117 if legacy_path.exists() {
118 let password = read_password_from_file(legacy_path, "legacy password file")?;
119 set_private_key_password_env(&password);
120 eprintln!(
121 "Using legacy password source '{}'. Prefer JACS_PRIVATE_KEY_PASSWORD or {}.",
122 legacy_path.display(),
123 CLI_PASSWORD_FILE_ENV
124 );
125 #[cfg(feature = "keychain")]
126 {
127 if jacs::keystore::keychain::is_available() {
128 eprintln!(
129 "Warning: A plaintext password file '{}' was found. \
130 Consider migrating to the OS keychain with `jacs keychain set` \
131 and then deleting the password file.",
132 legacy_path.display()
133 );
134 }
135 }
136 return Ok(Some(password));
137 }
138
139 Ok(None)
140}
141
142pub fn wrap_quickstart_error_with_password_help(
143 context: &str,
144 err: impl std::fmt::Display,
145) -> Box<dyn Error> {
146 Box::new(std::io::Error::other(format!(
147 "{}: {}\n\n{}",
148 context,
149 err,
150 quickstart_password_bootstrap_help()
151 )))
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use serial_test::serial;
158 use std::ffi::OsString;
159 use tempfile::tempdir;
160
161 struct EnvGuard {
162 saved: Vec<(&'static str, Option<OsString>)>,
163 }
164
165 impl EnvGuard {
166 fn capture(keys: &[&'static str]) -> Self {
167 Self {
168 saved: keys
169 .iter()
170 .map(|key| (*key, std::env::var_os(key)))
171 .collect(),
172 }
173 }
174 }
175
176 impl Drop for EnvGuard {
177 fn drop(&mut self) {
178 for (key, value) in self.saved.drain(..) {
179 match value {
180 Some(value) => {
181 unsafe {
183 std::env::set_var(key, value);
184 }
185 }
186 None => {
187 unsafe {
189 std::env::remove_var(key);
190 }
191 }
192 }
193 }
194 }
195 }
196
197 #[test]
198 fn quickstart_help_mentions_env_precedence_warning() {
199 let help = quickstart_password_bootstrap_help();
200 assert!(help.contains("prefer exactly one explicit source"));
201 assert!(help.contains("CLI warns and uses JACS_PRIVATE_KEY_PASSWORD"));
202 }
203
204 #[test]
205 #[serial]
206 fn ensure_cli_private_key_password_reads_password_file_when_env_absent() {
207 let _guard = EnvGuard::capture(&[PRIVATE_KEY_PASSWORD_ENV, CLI_PASSWORD_FILE_ENV]);
208 let temp = tempdir().expect("tempdir");
209 let password_file = temp.path().join("password.txt");
210 std::fs::write(&password_file, "TestP@ss123!#\n").expect("write password file");
211 #[cfg(unix)]
212 {
213 use std::os::unix::fs::PermissionsExt;
214 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
215 .expect("chmod password file");
216 }
217
218 unsafe {
220 std::env::remove_var(PRIVATE_KEY_PASSWORD_ENV);
221 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
222 }
223
224 let resolved =
225 ensure_cli_private_key_password().expect("password bootstrap should succeed");
226
227 assert_eq!(
228 resolved.as_deref(),
229 Some("TestP@ss123!#"),
230 "resolved password should match password file content"
231 );
232 assert_eq!(
233 std::env::var(PRIVATE_KEY_PASSWORD_ENV).expect("env password"),
234 "TestP@ss123!#"
235 );
236 }
237
238 #[test]
239 #[serial]
240 fn ensure_cli_private_key_password_prefers_env_when_sources_are_ambiguous() {
241 let _guard = EnvGuard::capture(&[PRIVATE_KEY_PASSWORD_ENV, CLI_PASSWORD_FILE_ENV]);
242 let temp = tempdir().expect("tempdir");
243 let password_file = temp.path().join("password.txt");
244 std::fs::write(&password_file, "DifferentP@ss456$\n").expect("write password file");
245 #[cfg(unix)]
246 {
247 use std::os::unix::fs::PermissionsExt;
248 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
249 .expect("chmod password file");
250 }
251
252 unsafe {
254 std::env::set_var(PRIVATE_KEY_PASSWORD_ENV, "TestP@ss123!#");
255 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
256 }
257
258 let resolved =
259 ensure_cli_private_key_password().expect("password bootstrap should succeed");
260
261 assert_eq!(
262 resolved.as_deref(),
263 Some("TestP@ss123!#"),
264 "env var should win over password file"
265 );
266 assert_eq!(
267 std::env::var(PRIVATE_KEY_PASSWORD_ENV).expect("env password"),
268 "TestP@ss123!#"
269 );
270 }
271}