use std::io::IsTerminal;
use zeroize::Zeroizing;
use zxcvbn::zxcvbn;
use crate::vault::VaultError;
pub const PASSPHRASE_ENV: &str = "MAGI_PASSPHRASE";
pub const MIN_PASSPHRASE_CHARS: usize = 12;
const MIN_ZXCVBN_SCORE: u8 = 3;
pub trait PassphrasePrompt {
fn is_interactive(&self) -> bool;
fn read_passphrase(&mut self, msg: &str, show: bool) -> Result<Zeroizing<String>, VaultError>;
}
pub struct TtyPrompt;
impl PassphrasePrompt for TtyPrompt {
fn is_interactive(&self) -> bool {
std::io::stdin().is_terminal()
}
fn read_passphrase(&mut self, msg: &str, show: bool) -> Result<Zeroizing<String>, VaultError> {
if show {
eprint!("{msg}");
use std::io::Write;
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| VaultError::Io(e.to_string()))?;
Ok(strip_trailing_newline(Zeroizing::new(line)))
} else {
rpassword::prompt_password(msg)
.map(Zeroizing::new)
.map_err(|e| VaultError::Io(e.to_string()))
}
}
}
pub fn strip_trailing_newline(s: Zeroizing<String>) -> Zeroizing<String> {
if let Some(stripped) = s.strip_suffix("\r\n") {
Zeroizing::new(stripped.to_string())
} else if let Some(stripped) = s.strip_suffix('\n') {
Zeroizing::new(stripped.to_string())
} else {
s
}
}
pub fn resolve_passphrase(
flag: Option<Zeroizing<String>>,
prompt: &mut dyn PassphrasePrompt,
) -> Result<Zeroizing<String>, VaultError> {
if let Some(p) = flag {
return Ok(strip_trailing_newline(p));
}
if let Ok(env) = std::env::var(PASSPHRASE_ENV) {
if !env.is_empty() {
return Ok(strip_trailing_newline(Zeroizing::new(env)));
}
}
if !prompt.is_interactive() {
return Err(VaultError::PassphraseUnavailable);
}
let entered = prompt.read_passphrase("Passphrase: ", false)?;
if entered.is_empty() {
return Err(VaultError::PassphraseUnavailable);
}
Ok(entered)
}
pub fn check_strength(passphrase: &str) -> Result<(), VaultError> {
if passphrase.chars().count() < MIN_PASSPHRASE_CHARS {
return Err(VaultError::WeakPassphrase(format!(
"too short (need at least {MIN_PASSPHRASE_CHARS} characters); \
a passphrase of 4+ random words is strong and easy to recall"
)));
}
let estimate = zxcvbn(passphrase, &[]);
if u8::from(estimate.score()) < MIN_ZXCVBN_SCORE {
let mut reason = String::from("too easy to guess");
if let Some(feedback) = estimate.feedback() {
if let Some(warning) = feedback.warning() {
reason = format!("{reason}: {warning}");
}
let tips: Vec<String> = feedback
.suggestions()
.iter()
.map(std::string::ToString::to_string)
.collect();
if !tips.is_empty() {
reason = format!("{reason} ({})", tips.join("; "));
}
}
return Err(VaultError::WeakPassphrase(format!(
"{reason}. Try 4+ random words; length matters more than symbols"
)));
}
Ok(())
}
const ZK_WARNING: &str =
"no recovery: if you forget the passphrase, the data is lost. There is no backdoor.";
pub fn create_passphrase(
prompt: &mut dyn PassphrasePrompt,
show: bool,
) -> Result<Zeroizing<String>, VaultError> {
loop {
let first = prompt.read_passphrase(&format!("New passphrase ({ZK_WARNING}): "), show)?;
if first.is_empty() {
return Err(VaultError::PassphraseUnavailable);
}
check_strength(first.as_str())?;
let second = prompt.read_passphrase("Confirm passphrase: ", show)?;
if first.as_str() == second.as_str() {
return Ok(first);
}
}
}
#[doc(hidden)]
pub fn fuzz_passphrase_entrypoint(data: &[u8]) {
let s = String::from_utf8_lossy(data);
let _ = check_strength(&s);
let vault = cryptovault::CryptoVault::default();
if let Ok(salt) = cryptovault::generate_salt() {
let _ = vault.derive_key(&s, &salt);
}
}
#[cfg(test)]
mod tests {
use super::{
check_strength, create_passphrase, resolve_passphrase, PassphrasePrompt, PASSPHRASE_ENV,
};
use crate::vault::VaultError;
use zeroize::Zeroizing;
struct FakePrompt {
interactive: bool,
answers: Vec<String>,
reads: usize,
shows: Vec<bool>,
msgs: Vec<String>,
}
impl PassphrasePrompt for FakePrompt {
fn is_interactive(&self) -> bool {
self.interactive
}
fn read_passphrase(
&mut self,
msg: &str,
show: bool,
) -> Result<Zeroizing<String>, VaultError> {
let i = self.reads;
self.reads += 1;
self.shows.push(show);
self.msgs.push(msg.to_string());
Ok(Zeroizing::new(
self.answers.get(i).cloned().unwrap_or_default(),
))
}
}
fn fp(interactive: bool, answers: Vec<&str>) -> FakePrompt {
FakePrompt {
interactive,
answers: answers.into_iter().map(Into::into).collect(),
reads: 0,
shows: vec![],
msgs: vec![],
}
}
fn with_var<R>(key: &str, val: Option<&str>, f: impl FnOnce() -> R) -> R {
struct Guard {
key: String,
prev: Option<String>,
}
impl Drop for Guard {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
let _g = Guard {
key: key.to_string(),
prev: std::env::var(key).ok(),
};
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
f()
}
#[test]
#[serial_test::serial]
fn test_flag_takes_precedence_over_env_and_prompt() {
let mut p = fp(true, vec!["from-prompt"]);
with_var(PASSPHRASE_ENV, Some("from-env"), || {
let r =
resolve_passphrase(Some(Zeroizing::new("from-flag".into())), &mut p).expect("ok");
assert_eq!(r.as_str(), "from-flag");
assert_eq!(p.reads, 0);
});
}
#[test]
#[serial_test::serial]
fn test_env_takes_precedence_over_prompt_and_empty_env_counts_as_absent() {
let mut p = fp(true, vec!["from-prompt"]);
with_var(PASSPHRASE_ENV, Some("from-env"), || {
assert_eq!(
resolve_passphrase(None, &mut p).expect("ok").as_str(),
"from-env"
);
});
with_var(PASSPHRASE_ENV, Some(""), || {
assert_eq!(
resolve_passphrase(None, &mut p).expect("ok").as_str(),
"from-prompt"
);
});
}
#[test]
#[serial_test::serial]
fn test_no_tty_without_flag_or_env_fails_closed_without_reading_stdin() {
let mut p = fp(false, vec!["pipe-data"]);
with_var(PASSPHRASE_ENV, None, || {
let e = resolve_passphrase(None, &mut p).expect_err("fail-closed");
assert!(matches!(e, VaultError::PassphraseUnavailable));
assert_eq!(p.reads, 0);
});
}
#[test]
#[serial_test::serial]
fn test_trailing_newline_stripped_from_flag_and_env_but_not_inner_whitespace() {
let mut p = fp(true, vec!["x"]);
let r =
resolve_passphrase(Some(Zeroizing::new("pass phrase\n".into())), &mut p).expect("ok");
assert_eq!(r.as_str(), "pass phrase");
with_var(PASSPHRASE_ENV, Some("secret \r\n"), || {
let e = resolve_passphrase(None, &mut p).expect("ok");
assert_eq!(e.as_str(), "secret ");
});
}
#[test]
fn test_short_or_low_score_passphrases_are_hard_rejected_without_override() {
for weak in ["short", "password123!", "qwertyuiop12"] {
assert!(
matches!(check_strength(weak), Err(VaultError::WeakPassphrase(_))),
"should reject: {weak}"
);
}
}
#[test]
fn test_diceware_lowercase_words_are_accepted_without_composition_rules() {
check_strength("correct horse battery staple").expect("diceware >= 3");
}
#[test]
fn test_weak_passphrase_message_never_contains_the_passphrase() {
let probe = "hunter2hunter2";
if let Err(VaultError::WeakPassphrase(msg)) = check_strength(probe) {
assert!(!msg.contains(probe));
}
}
#[test]
fn test_create_passphrase_requires_matching_double_entry() {
let mut p = fp(
true,
vec![
"correct horse battery staple",
"MISMATCH-XYZ",
"correct horse battery staple",
"correct horse battery staple",
],
);
let r = create_passphrase(&mut p, false).expect("ok");
assert_eq!(r.as_str(), "correct horse battery staple");
assert_eq!(p.reads, 4);
}
#[test]
fn test_create_passphrase_threads_show_flag_to_both_reads() {
let mut p = fp(true, vec!["correct horse battery staple"; 2]);
create_passphrase(&mut p, true).expect("ok");
assert_eq!(p.shows, vec![true, true]);
}
#[test]
fn test_create_passphrase_emits_zero_knowledge_no_recovery_warning() {
let mut p = fp(true, vec!["correct horse battery staple"; 2]);
create_passphrase(&mut p, false).expect("ok");
assert!(p
.msgs
.iter()
.any(|m| m.to_lowercase().contains("no recovery")
|| m.to_lowercase().contains("data is lost")));
}
#[test]
fn test_fuzz_passphrase_entrypoint_never_panics_on_arbitrary_input() {
for data in [
&b""[..],
&b"\x00"[..],
&b"\xff\xfe\x80"[..], &[0x41u8; 100_000], ] {
super::fuzz_passphrase_entrypoint(data);
}
}
}