Skip to main content

jacs_cli/
password_bootstrap.rs

1use 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    // SAFETY: CLI process is single-threaded for command handling at this point.
23    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
86/// Resolve the private key password from CLI sources and return it.
87///
88/// Returns `Ok(Some(password))` when a password is found from env var,
89/// password file, or legacy file. Returns `Ok(None)` when no CLI-level
90/// password is available (the core layer will try the OS keychain).
91///
92/// Also sets the `JACS_PRIVATE_KEY_PASSWORD` env var as a side-effect
93/// for backward compatibility with code paths that still read it.
94pub 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                        // SAFETY: These unit tests are marked serial and restore prior process env.
182                        unsafe {
183                            std::env::set_var(key, value);
184                        }
185                    }
186                    None => {
187                        // SAFETY: These unit tests are marked serial and restore prior process env.
188                        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        // SAFETY: These unit tests are marked serial and restore prior process env.
219        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        // SAFETY: These unit tests are marked serial and restore prior process env.
253        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}