diff --git a/Cargo.lock b/Cargo.lock index d44b5c5..dd25761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2639,6 +2639,7 @@ dependencies = [ "md-5", "rand 0.8.8", "ratatui", + "rpassword", "rusqlite", "safelog", "serde_json", @@ -3381,6 +3382,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3402,6 +3414,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.36.0" diff --git a/Cargo.toml b/Cargo.toml index 3d82721..9643519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ argon2 = "0.5" chacha20poly1305 = "0.10" sha3 = "0.10" md-5 = "0.10" +rpassword = "7" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index f6f3054..29785e0 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ onionwire or `cargo run --release`. -On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. +On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. diff --git a/src/lib.rs b/src/lib.rs index efcab3a..484841c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,4 +13,7 @@ mod store; pub mod tui; pub mod wallet; -pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store}; +pub use store::{ + Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store, + resolve_store_passphrase, +}; diff --git a/src/main.rs b/src/main.rs index c37cf2f..78027ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use onionwire::tui::AppExit; -use std::io::{self, Write}; fn print_help() { let v = env!("CARGO_PKG_VERSION"); @@ -35,7 +34,8 @@ async fn main() { async fn boot() -> Result<(), String> { let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?; - let pass = store_passphrase()?; + let pass = onionwire::resolve_store_passphrase(|p| rpassword::prompt_password(p)) + .map_err(|e| e.to_string())?; eprintln!("onionwire: bootstrapping Arti…"); let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?; let handle = tokio::runtime::Handle::current(); @@ -48,22 +48,3 @@ async fn boot() -> Result<(), String> { } Ok(()) } - -fn store_passphrase() -> Result { - match std::env::var("ONIONWIRE_STORE_PASSPHRASE") { - Ok(p) if p.is_empty() => Err("empty passphrase".into()), - Ok(p) => Ok(p), - Err(_) => { - eprint!("onionwire: store passphrase: "); - let _ = io::stderr().flush(); - let mut s = String::new(); - io::stdin().read_line(&mut s).map_err(|e| e.to_string())?; - let s = s.trim_end_matches(['\n', '\r']).to_string(); - if s.is_empty() { - Err("empty passphrase".into()) - } else { - Ok(s) - } - } - } -} diff --git a/src/store.rs b/src/store.rs index c2ad26f..1be94f3 100644 --- a/src/store.rs +++ b/src/store.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; -use rand::rngs::OsRng; use rand::RngCore; -use rusqlite::{params, Connection, OptionalExtension}; +use rand::rngs::OsRng; +use rusqlite::{Connection, OptionalExtension, params}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; @@ -831,6 +831,26 @@ fn passphrase_from_env() -> Result { } } +/// Env `ONIONWIRE_STORE_PASSPHRASE` if set (non-empty). Otherwise `read_secret` +/// (TTY, no echo). Empty values fail closed. +pub fn resolve_store_passphrase( + read_secret: impl FnOnce(&str) -> std::io::Result, +) -> Result { + match std::env::var("ONIONWIRE_STORE_PASSPHRASE") { + Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())), + Ok(p) => Ok(p), + Err(_) => { + let s = read_secret("onionwire: store passphrase: ")?; + let s = s.trim_end_matches(['\n', '\r']).to_string(); + if s.is_empty() { + Err(Error("empty passphrase".into())) + } else { + Ok(s) + } + } + } +} + fn mkdir_700(path: &Path) -> Result<()> { fs::create_dir_all(path)?; let mut perms = fs::metadata(path)?.permissions(); diff --git a/tests/store.rs b/tests/store.rs index bf993eb..7bc688a 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -264,3 +264,64 @@ fn reopen_with_same_passphrase_decrypts() { let msgs = store.list_messages(&pk(1)).unwrap(); assert_eq!(msgs[0].plaintext, b"hello again"); } + +#[test] +fn env_passphrase_skips_prompt() { + let _g = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env"); + } + let mut prompted = false; + let got = onionwire::resolve_store_passphrase(|_| { + prompted = true; + Ok("from-tty".into()) + }); + unsafe { + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); + } + assert_eq!(got.unwrap(), "from-env"); + assert!(!prompted); +} + +#[test] +fn empty_env_passphrase_is_rejected_without_prompt() { + let _g = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", ""); + } + let mut prompted = false; + let err = onionwire::resolve_store_passphrase(|_| { + prompted = true; + Ok("from-tty".into()) + }) + .unwrap_err(); + unsafe { + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); + } + assert!(err.to_string().contains("empty"), "got {err}"); + assert!(!prompted); +} + +#[test] +fn missing_env_reads_secret_and_strips_newline() { + let _g = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); + } + let got = onionwire::resolve_store_passphrase(|prompt| { + assert!(prompt.contains("store passphrase"), "prompt {prompt}"); + Ok("secret-from-tty\n".into()) + }) + .unwrap(); + assert_eq!(got, "secret-from-tty"); +} + +#[test] +fn missing_env_empty_secret_is_rejected() { + let _g = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); + } + let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err(); + assert!(err.to_string().contains("empty"), "got {err}"); +}