fix(cli): hide store passphrase like a password prompt #14

Merged
sirius merged 2 commits from feat/hidden-store-passphrase into main 2026-09-11 01:18:47 +00:00
7 changed files with 113 additions and 25 deletions
Showing only changes of commit c5019f1737 - Show all commits

22
Cargo.lock generated
View file

@ -2639,6 +2639,7 @@ dependencies = [
"md-5", "md-5",
"rand 0.8.8", "rand 0.8.8",
"ratatui", "ratatui",
"rpassword",
"rusqlite", "rusqlite",
"safelog", "safelog",
"serde_json", "serde_json",
@ -3381,6 +3382,17 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@ -3402,6 +3414,16 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.36.0" version = "0.36.0"

View file

@ -45,6 +45,7 @@ argon2 = "0.5"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
sha3 = "0.10" sha3 = "0.10"
md-5 = "0.10" md-5 = "0.10"
rpassword = "7"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -108,7 +108,7 @@ onionwire
or `cargo run --release`. 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 friends. Mouse-select the `onionwire:v1:…` line to copy. Then `F2` to share your invite, `F3` to paste a friends. Mouse-select the `onionwire:v1:…` line to copy.

View file

@ -13,4 +13,7 @@ mod store;
pub mod tui; pub mod tui;
pub mod wallet; 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,
};

View file

@ -1,5 +1,4 @@
use onionwire::tui::AppExit; use onionwire::tui::AppExit;
use std::io::{self, Write};
fn print_help() { fn print_help() {
let v = env!("CARGO_PKG_VERSION"); let v = env!("CARGO_PKG_VERSION");
@ -35,7 +34,8 @@ async fn main() {
async fn boot() -> Result<(), String> { async fn boot() -> Result<(), String> {
let home = onionwire::Store::home_dir().map_err(|e| e.to_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…"); eprintln!("onionwire: bootstrapping Arti…");
let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?; let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?;
let handle = tokio::runtime::Handle::current(); let handle = tokio::runtime::Handle::current();
@ -48,22 +48,3 @@ async fn boot() -> Result<(), String> {
} }
Ok(()) Ok(())
} }
fn store_passphrase() -> Result<String, String> {
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)
}
}
}
}

View file

@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension}; use rand::rngs::OsRng;
use rusqlite::{Connection, OptionalExtension, params};
use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -831,6 +831,26 @@ fn passphrase_from_env() -> Result<String> {
} }
} }
/// 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<String>,
) -> Result<String> {
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<()> { fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?; fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions(); let mut perms = fs::metadata(path)?.permissions();

View file

@ -264,3 +264,64 @@ fn reopen_with_same_passphrase_decrypts() {
let msgs = store.list_messages(&pk(1)).unwrap(); let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs[0].plaintext, b"hello again"); 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}");
}