Skip to main content

shadow_crypt_shell/
password.rs

1use std::path::Path;
2
3use rpassword;
4use shadow_crypt_core::{memory::SecureString, profile::SecurityProfile};
5
6use crate::errors::{WorkflowError, WorkflowResult};
7
8/// Prompt user for password
9pub fn prompt_for_password() -> WorkflowResult<SecureString> {
10    let password = rpassword::prompt_password("Enter password: ")
11        .map_err(|e| WorkflowError::Password(format!("Failed to read password: {}", e)))
12        .map(SecureString::new)?;
13
14    Ok(password)
15}
16
17/// Reads a password from a file for non-interactive use. A single trailing
18/// newline (as left by most editors and `echo`) is stripped; everything else
19/// is taken verbatim.
20pub fn read_password_from_file(path: &Path) -> WorkflowResult<SecureString> {
21    let mut content = std::fs::read_to_string(path).map_err(|e| {
22        WorkflowError::Password(format!(
23            "Failed to read password file '{}': {}",
24            path.display(),
25            e
26        ))
27    })?;
28    if content.ends_with('\n') {
29        content.pop();
30        if content.ends_with('\r') {
31            content.pop();
32        }
33    }
34
35    let password = SecureString::new(content);
36    validate_password_format(&password)?;
37    Ok(password)
38}
39
40/// Password for decryption or listing: read from `password_file` when given,
41/// otherwise prompt interactively.
42pub fn resolve_password(password_file: Option<&Path>) -> WorkflowResult<SecureString> {
43    match password_file {
44        Some(path) => read_password_from_file(path),
45        None => prompt_for_password(),
46    }
47}
48
49/// Password for encryption: read from `password_file` when given (strength
50/// requirements still apply, but no confirmation is needed since there is no
51/// typo risk), otherwise prompt interactively with confirmation.
52pub fn resolve_encryption_password(
53    password_file: Option<&Path>,
54    security_profile: &SecurityProfile,
55) -> WorkflowResult<SecureString> {
56    match password_file {
57        Some(path) => {
58            let password = read_password_from_file(path)?;
59            validate_password_requirements(&password, security_profile)?;
60            Ok(password)
61        }
62        None => prompt_for_password_with_confirmation(security_profile),
63    }
64}
65
66/// Prompt user for password with confirmation
67pub fn prompt_for_password_with_confirmation(
68    security_profile: &SecurityProfile,
69) -> WorkflowResult<SecureString> {
70    let password1 = rpassword::prompt_password("Enter password: ")
71        .map_err(|e| WorkflowError::Password(format!("Failed to read password: {}", e)))
72        .map(SecureString::new)?;
73
74    let password2 = rpassword::prompt_password("Confirm password: ")
75        .map_err(|e| WorkflowError::Password(format!("Failed to read password: {}", e)))
76        .map(SecureString::new)?;
77
78    constant_time_eq(password1.as_str().as_bytes(), password2.as_str().as_bytes())
79        .then_some(())
80        .ok_or(WorkflowError::Password(
81            "Passwords do not match".to_string(),
82        ))?;
83
84    validate_password_requirements(&password1, security_profile)
85        .map_err(|e| WorkflowError::Password(e.to_string()))?;
86
87    Ok(password1)
88}
89
90pub fn validate_password_format(password: &SecureString) -> Result<(), WorkflowError> {
91    if password.is_empty() {
92        return Err(WorkflowError::Password(
93            "Password cannot be empty".to_string(),
94        ));
95    }
96
97    Ok(())
98}
99
100fn validate_password_requirements(
101    password: &SecureString,
102    security_profile: &SecurityProfile,
103) -> Result<(), WorkflowError> {
104    // Basic format check
105    validate_password_format(password)?;
106
107    // Professional entropy validation (the only security requirement that matters)
108    match security_profile {
109        SecurityProfile::Test => Ok(()), // Skip entropy check in the test profile
110        SecurityProfile::Standard | SecurityProfile::Paranoid => {
111            validate_password_entropy(password)
112        }
113    }
114}
115
116/// Validate password strength using zxcvbn industry-standard algorithm
117/// Pure function - no side effects
118fn validate_password_entropy(password: &SecureString) -> Result<(), WorkflowError> {
119    let estimate = zxcvbn::zxcvbn(password.as_str(), &[]);
120
121    // Score 3 = "Safely unguessable: moderate protection from offline slow-hash scenario"
122    if estimate.score() < zxcvbn::Score::Three {
123        let feedback_msg = format_feedback(&estimate);
124        return Err(WorkflowError::Password(format!(
125            "Password strength insufficient (score {}/4). {}. Estimated crack time: {}",
126            estimate.score(),
127            feedback_msg,
128            estimate.crack_times().offline_slow_hashing_1e4_per_second()
129        )));
130    }
131
132    Ok(())
133}
134
135/// Format zxcvbn feedback into user-friendly message
136fn format_feedback(estimate: &zxcvbn::Entropy) -> String {
137    if let Some(feedback) = estimate.feedback() {
138        let mut suggestions = Vec::new();
139
140        if let Some(warning) = feedback.warning() {
141            suggestions.push(warning.to_string());
142        }
143
144        suggestions.extend(feedback.suggestions().iter().map(|s| s.to_string()));
145
146        if suggestions.is_empty() {
147            "Use a stronger password".to_string()
148        } else {
149            suggestions.join(". ")
150        }
151    } else {
152        "Use a stronger password".to_string()
153    }
154}
155
156use subtle::ConstantTimeEq;
157
158/// Compares two byte slices for equality in constant time when lengths are equal.
159/// A length mismatch is detected immediately (not in constant time), which is
160/// acceptable for interactive password confirmation but not for HMAC verification.
161fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
162    if a.len() != b.len() {
163        return false;
164    }
165    a.ct_eq(b).into()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use shadow_crypt_core::profile::SecurityProfile;
172
173    #[test]
174    fn test_validate_password_format_empty() {
175        let empty = SecureString::new(String::new());
176        assert!(validate_password_format(&empty).is_err());
177    }
178
179    #[test]
180    fn test_validate_password_format_non_empty() {
181        let password = SecureString::new("test".to_string());
182        assert!(validate_password_format(&password).is_ok());
183    }
184
185    #[test]
186    fn test_validate_password_requirements_test_profile() {
187        let password = SecureString::new("weak".to_string());
188        assert!(validate_password_requirements(&password, &SecurityProfile::Test).is_ok());
189    }
190
191    #[test]
192    fn test_validate_password_requirements_production_weak() {
193        let password = SecureString::new("password".to_string());
194        assert!(validate_password_requirements(&password, &SecurityProfile::Standard).is_err());
195    }
196
197    #[test]
198    fn test_validate_password_requirements_production_strong() {
199        let password = SecureString::new("Tr0ub4dour&3!".to_string());
200        assert!(validate_password_requirements(&password, &SecurityProfile::Standard).is_ok());
201    }
202
203    #[test]
204    fn test_validate_password_entropy_weak() {
205        let password = SecureString::new("123456".to_string());
206        assert!(validate_password_entropy(&password).is_err());
207    }
208
209    #[test]
210    fn test_validate_password_entropy_strong() {
211        let password = SecureString::new("CorrectHorseBatteryStaple".to_string());
212        assert!(validate_password_entropy(&password).is_ok());
213    }
214
215    #[test]
216    fn test_format_feedback_no_feedback() {
217        // Use a very strong password that likely has no feedback
218        let estimate = zxcvbn::zxcvbn("Tr0ub4dour&3!BatteryStaple", &[]);
219        let feedback = format_feedback(&estimate);
220        // If it has feedback, it should be formatted; if not, default message
221        // This test ensures the function doesn't panic and returns a string
222        assert!(!feedback.is_empty());
223    }
224
225    #[test]
226    fn test_constant_time_eq_equal() {
227        let a = b"test";
228        let b = b"test";
229        assert!(constant_time_eq(a, b));
230    }
231
232    #[test]
233    fn test_constant_time_eq_not_equal() {
234        let a = b"test";
235        let b = b"different";
236        assert!(!constant_time_eq(a, b));
237    }
238
239    #[test]
240    fn test_constant_time_eq_different_lengths() {
241        let a = b"test";
242        let b = b"testing";
243        assert!(!constant_time_eq(a, b));
244    }
245
246    #[test]
247    fn test_read_password_from_file() {
248        let mut file = tempfile::NamedTempFile::new().unwrap();
249        std::io::Write::write_all(&mut file, b"secret-password\n").unwrap();
250
251        let password = read_password_from_file(file.path()).unwrap();
252        assert_eq!(password.as_str(), "secret-password");
253    }
254
255    #[test]
256    fn test_read_password_from_file_strips_crlf() {
257        let mut file = tempfile::NamedTempFile::new().unwrap();
258        std::io::Write::write_all(&mut file, b"secret-password\r\n").unwrap();
259
260        let password = read_password_from_file(file.path()).unwrap();
261        assert_eq!(password.as_str(), "secret-password");
262    }
263
264    #[test]
265    fn test_read_password_from_file_keeps_inner_content_verbatim() {
266        let mut file = tempfile::NamedTempFile::new().unwrap();
267        std::io::Write::write_all(&mut file, b"  spaced password ").unwrap();
268
269        let password = read_password_from_file(file.path()).unwrap();
270        assert_eq!(password.as_str(), "  spaced password ");
271    }
272
273    #[test]
274    fn test_read_password_from_empty_file_rejected() {
275        let file = tempfile::NamedTempFile::new().unwrap();
276        assert!(read_password_from_file(file.path()).is_err());
277    }
278
279    #[test]
280    fn test_read_password_from_missing_file_rejected() {
281        let result = read_password_from_file(std::path::Path::new("/nonexistent/password"));
282        assert!(result.is_err());
283    }
284}