Merge pull request 'feat(store): encrypt chat bodies at rest; Ctrl-Q CLEAR/QUIT' (#1) from wt/t_b0456c4b into main

Reviewed-on: #1
This commit is contained in:
sirius 2026-09-10 17:29:45 -04:00
commit a7b5531501
16 changed files with 461 additions and 35 deletions

View file

@ -3,7 +3,7 @@
Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti).
No hosted server. Identity is a public key; the onion is only a locator.
**v0.2** — Linux TUI. Two people add each other with an `onionwire:v1:…` invite string (`F2` share / `F3` paste). There is no QR graphic. Chat is Noise IK over your own v3 onion. Optional Monero invoices if you point at a local `monero-wallet-rpc`. Fail closed: peer down → send fails. Arti onion services are still experimental. The message log is plaintext on disk.
**v0.2** — Linux TUI. Two people add each other with an `onionwire:v1:…` invite string (`F2` share / `F3` paste). There is no QR graphic. Chat is Noise IK over your own v3 onion. Optional Monero invoices if you point at a local `monero-wallet-rpc`. Fail closed: peer down → send fails. Arti onion services are still experimental. Chat bodies are encrypted at rest; identity keys in sqlite are still plaintext.
You do **not** install a `tor` daemon, `torrc`, Prosody, or XMPP. OnionWire embeds Arti and publishes its own v3 onion.
@ -108,7 +108,7 @@ onionwire
or `cargo run --release`.
On start 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 (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.
@ -133,7 +133,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
| `F5` | Selected friends profile (`/who`) |
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
| `Esc` | Close overlay / back to Main / clear composer |
| `Ctrl-Q` | Quit |
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
## F2 / F3 invite
@ -208,13 +208,14 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
## Seized laptop
v1 stores **plaintext** on disk:
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log.
Still plaintext on disk (unless you add OS/FDE):
- message log (sqlite `messages.plaintext`)
- your identity secret key (`self.identity_sk`)
- friend public keys and current locators
Full-disk encryption plus `/wipe` / `/wipe-all` is the mitigation. There is no sqlcipher in v1.
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).

View file

@ -14,9 +14,11 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only
`F4` changes the locator only. The identity key is unchanged. Anyone who already has your pubkey can still prove they are talking to you, and a later invite paste with the same `k` updates their row. To become a new person, `/wipe-all` (new identity key). There is no in-band unfriend/revoke in v1.
## v1 stores plaintext locally
## Message bodies are encrypted at rest; keys are not
The message log, identity secret key, and friend public keys sit on disk unencrypted (aside from whatever the OS/FDE provides). A seized laptop yields the chat history and who you talk to. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. sqlcipher is out of v1.
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column). A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir.
## Fail closed

View file

@ -42,7 +42,7 @@ impl BackupKeys {
}
}
fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> {
pub(crate) fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> {
if passphrase.is_empty() {
return Err("empty passphrase".into());
}
@ -94,3 +94,26 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
.map_err(|_| "wrong passphrase or corrupt backup".to_string())?;
BackupKeys::from_bytes(&pt)
}
pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into());
}
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher
.decrypt(nonce, &blob[NONCE_LEN..])
.map_err(|_| "wrong passphrase or corrupt".to_string())
}

View file

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

@ -50,7 +50,19 @@ pub struct Node {
impl Node {
pub async fn start(home: PathBuf) -> Result<Arc<Self>, String> {
let store = Store::open_at(&home).map_err(|e| e.to_string())?;
let pass = std::env::var("ONIONWIRE_STORE_PASSPHRASE")
.map_err(|_| "ONIONWIRE_STORE_PASSPHRASE required".to_string())?;
if pass.is_empty() {
return Err("empty passphrase".into());
}
Self::start_with_passphrase(home, &pass).await
}
pub async fn start_with_passphrase(
home: PathBuf,
passphrase: &str,
) -> Result<Arc<Self>, String> {
let store = Store::open_at_with_passphrase(&home, passphrase).map_err(|e| e.to_string())?;
let me = store.self_identity().map_err(|e| e.to_string())?;
let keys = Keys::from_self(&me).map_err(|e| e.to_string())?;
let nickname = store.hs_nickname().map_err(|e| e.to_string())?;

View file

@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::SigningKey;
use rand::RngCore;
use rand::rngs::OsRng;
use rusqlite::{Connection, OptionalExtension, params};
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
@ -33,8 +34,15 @@ impl From<std::io::Error> for Error {
}
}
impl From<String> for Error {
fn from(e: String) -> Self {
Self(e)
}
}
pub struct Store {
conn: Connection,
msg_key: [u8; 32],
}
pub struct SelfIdentity {
@ -100,6 +108,13 @@ impl Store {
}
pub fn open_at(home: &Path) -> Result<Self> {
Self::open_at_with_passphrase(home, &passphrase_from_env()?)
}
pub fn open_at_with_passphrase(home: &Path, passphrase: &str) -> Result<Self> {
if passphrase.is_empty() {
return Err(Error("empty passphrase".into()));
}
mkdir_700(home)?;
mkdir_700(&home.join("arti"))?;
let db_path = home.join("onionwire.db");
@ -153,6 +168,11 @@ impl Store {
xmr_addr TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS store_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
kdf_salt BLOB NOT NULL,
wrapped_key BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY,
friend_id INTEGER NOT NULL REFERENCES friends(id),
@ -172,9 +192,13 @@ impl Store {
if check != "ok" {
return Err(Error(format!("integrity_check: {check}")));
}
let store = Self { conn };
let mut store = Self {
conn,
msg_key: [0u8; 32],
};
store.migrate()?;
store.ensure_self()?;
store.unlock_messages(passphrase)?;
Ok(store)
}
@ -234,6 +258,66 @@ impl Store {
Ok(())
}
fn unlock_messages(&mut self, passphrase: &str) -> Result<()> {
let row: Option<(Vec<u8>, Vec<u8>)> = self
.conn
.query_row(
"SELECT kdf_salt, wrapped_key FROM store_meta WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
match row {
Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped)
.map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 {
return Err(Error("wrapped message key length".into()));
}
self.msg_key.copy_from_slice(&raw);
Ok(())
}
None => {
let mut data_key = [0u8; 32];
OsRng.fill_bytes(&mut data_key);
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?;
self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped],
)?;
self.msg_key = data_key;
self.reencrypt_legacy_messages()?;
Ok(())
}
}
}
fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?;
let rows: Vec<(i64, Vec<u8>)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<_, _>>()?;
drop(stmt);
if rows.is_empty() {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
for (id, plain) in rows {
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?;
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id],
)?;
}
tx.commit()?;
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
Ok(())
}
fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> {
let sql =
format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'");
@ -562,9 +646,10 @@ impl Store {
|row| row.get(0),
)
.map_err(|_| Error("friend not found".into()))?;
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?;
self.conn.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)",
params![friend_id, dir, plaintext, unix_now()],
params![friend_id, dir, blob, unix_now()],
)?;
Ok(())
}
@ -577,14 +662,14 @@ impl Store {
ORDER BY m.id",
)?;
let rows = stmt.query_map(params![friend_pk], |row| {
Ok(Message {
dir: row.get(0)?,
plaintext: row.get(1)?,
})
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
let (dir, blob) = row?;
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob)
.map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext });
}
Ok(out)
}
@ -657,6 +742,14 @@ fn home_dir() -> Result<PathBuf> {
Ok(PathBuf::from(home).join(".local/share/onionwire"))
}
fn passphrase_from_env() -> 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(_) => Err(Error("ONIONWIRE_STORE_PASSPHRASE required".into())),
}
}
fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions();
@ -675,3 +768,45 @@ fn unix_now() -> i64 {
fn fingerprint(pubkey: &[u8]) -> String {
pubkey.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod at_rest {
use super::*;
#[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
let pk = [1u8; 32];
store.upsert_friend(&pk, "a.onion", None).unwrap();
store
.append_message(&pk, "out", b"legacy-plain-xyz")
.unwrap();
store.conn.execute("DELETE FROM store_meta", []).unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1",
params![b"legacy-plain-xyz".as_slice()],
)
.unwrap();
let _ = store.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
let msgs = store.list_messages(&pk).unwrap();
assert_eq!(msgs[0].plaintext, b"legacy-plain-xyz");
drop(store);
let needle = b"legacy-plain-xyz";
for name in ["onionwire.db", "onionwire.db-wal", "onionwire.db-shm"] {
let path = dir.path().join(name);
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
assert!(
!bytes.windows(needle.len()).any(|w| w == needle),
"{name} still contains legacy plaintext"
);
}
}
}

View file

@ -31,6 +31,15 @@ Offline friends CANNOT find you until they F3-paste your new invite.\n\
Type ROTATE to confirm Esc to cancel"
}
pub fn quit_screen_text() -> &'static str {
"Quit OnionWire?\n\
Message history can be cleared; identity and friends stay.\n\
This is not /wipe-all.\n\
Type CLEAR then Enter to clear history and quit.\n\
Type QUIT then Enter to quit without clearing.\n\
Esc to cancel"
}
/// Full Main-screen wordmark. Every line is <= 80 ASCII characters.
pub fn wordmark_banner() -> &'static str {
"\
@ -71,7 +80,7 @@ OnionWire keys\n\
/backup /path encrypted identity export\n\
/restore /path overwrite self keys\n\
? this help\n\
Ctrl-Q quit\n\
Ctrl-Q quit; type CLEAR or QUIT\n\
\n\
Esc closes this overlay"
}
@ -415,6 +424,52 @@ impl RotatePrompt {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuitDecision {
Pending,
ClearAndQuit,
Quit,
Cancel,
}
#[derive(Debug, Default)]
pub struct QuitPrompt {
buf: String,
}
impl QuitPrompt {
pub fn new() -> Self {
Self::default()
}
pub fn on_esc(&self) -> QuitDecision {
QuitDecision::Cancel
}
pub fn on_char(&mut self, c: char) -> QuitDecision {
if c == '\n' {
return match self.buf.as_str() {
"CLEAR" => QuitDecision::ClearAndQuit,
"QUIT" => QuitDecision::Quit,
_ => QuitDecision::Pending,
};
}
if c == '\u{8}' {
self.buf.pop();
return QuitDecision::Pending;
}
if !c.is_ascii_alphabetic() {
return QuitDecision::Pending;
}
self.buf.push(c);
QuitDecision::Pending
}
pub fn typed(&self) -> &str {
&self.buf
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackupKind {
Backup,
@ -558,6 +613,9 @@ enum Screen {
first: Option<String>,
buf: String,
},
Quit {
prompt: QuitPrompt,
},
}
impl App {
@ -591,7 +649,12 @@ impl App {
continue;
}
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(AppExit::Quit);
if !matches!(self.screen, Screen::Quit { .. }) {
self.screen = Screen::Quit {
prompt: QuitPrompt::new(),
};
}
continue;
}
if let Some(exit) = self.handle_key(key)? {
return Ok(exit);
@ -624,6 +687,7 @@ impl App {
}
let mut wipe_confirm = None;
let mut quit_clear = None;
let mut profile_save = None;
let mut open_profile = false;
let mut pay_cmd = None;
@ -810,10 +874,31 @@ impl App {
}
_ => {}
},
Screen::Quit { prompt } => match key.code {
KeyCode::Esc => self.screen = Screen::Main,
KeyCode::Enter => match prompt.on_char('\n') {
QuitDecision::ClearAndQuit => quit_clear = Some(true),
QuitDecision::Quit => quit_clear = Some(false),
_ => {}
},
KeyCode::Backspace => {
prompt.on_char('\u{8}');
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
prompt.on_char(c);
}
_ => {}
},
}
if let Some(kind) = wipe_confirm {
return self.confirm_wipe(kind);
}
if let Some(clear) = quit_clear {
if clear {
self.node.wipe_messages()?;
}
return Ok(Some(AppExit::Quit));
}
if open_profile {
self.open_profile()?;
}
@ -1098,6 +1183,7 @@ impl App {
Screen::Passphrase {
kind, first, buf, ..
} => draw_passphrase(f, *kind, first.is_some(), buf),
Screen::Quit { prompt } => draw_quit(f, prompt.typed()),
}
if self.help_open {
draw_text_overlay(f, "? help", help_overlay_text());
@ -1242,7 +1328,10 @@ impl App {
fn chat_lines(&self) -> Vec<String> {
let Some(friend) = self.friends.get(self.selected) else {
return vec!["no friends yet".into(), "F3 paste an invite to add one".into()];
return vec![
"no friends yet".into(),
"F3 paste an invite to add one".into(),
];
};
match self.node.list_messages(&friend.pubkey) {
Ok(msgs) if msgs.is_empty() => vec!["no messages".into()],
@ -1342,6 +1431,7 @@ fn footer_hints(screen: &Screen) -> &'static str {
BackupKind::Restore => "type RESTORE Esc cancel ? help",
},
Screen::Passphrase { .. } => "Enter submit Esc cancel ? help",
Screen::Quit { .. } => "CLEAR or QUIT then Enter Esc cancel ? help",
}
}
@ -1428,6 +1518,17 @@ fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
);
}
fn draw_quit(f: &mut Frame, typed: &str) {
let body = format!("{}\n\n{typed}", quit_screen_text());
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block("(o) quit")),
f.area(),
);
}
fn draw_profile(f: &mut Frame, editor: &ProfileEditor) {
let labels = ["name", "bio", "xmr"];
let values = [editor.display_name(), editor.bio(), editor.xmr_addr()];

View file

@ -57,7 +57,7 @@ fn garbage_file_fails() {
#[test]
fn restore_overwrites_self_keys_friends_stay() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
store
.upsert_friend(&[9u8; 32], "alice.onion", Some("alice"))
.unwrap();

View file

@ -8,7 +8,7 @@ use rand::rngs::OsRng;
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}

View file

@ -8,7 +8,7 @@ use rand::rngs::OsRng;
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}

View file

@ -2,8 +2,8 @@
use std::sync::{Mutex, MutexGuard};
use onionwire::qr;
use onionwire::Store;
use onionwire::qr;
static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -18,6 +18,7 @@ impl TempHome {
let guard = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
Self {
_dir: dir,
@ -30,6 +31,7 @@ impl Drop for TempHome {
fn drop(&mut self) {
unsafe {
std::env::remove_var("ONIONWIRE_HOME");
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
}
}

View file

@ -14,7 +14,7 @@ fn pk(tag: u8) -> [u8; 32] {
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}

View file

@ -23,6 +23,9 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() {
let carol_home = root.path().join("carol");
eprintln!("starting alice + bob…");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
let (alice, bob) = tokio::join!(
Node::start(alice_home.clone()),
Node::start(bob_home.clone())
@ -35,7 +38,7 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() {
alice.add_friend_from_qr(&b_qr).expect("alice adds bob");
bob.add_friend_from_qr(&a_qr).expect("bob adds alice");
let carol = Store::open_at(&carol_home).expect("carol store");
let carol = Store::open_at_with_passphrase(&carol_home, "onionwire-test").expect("carol store");
let a_pk = alice.identity_pk();
carol
.upsert_friend(&a_pk, &alice.onion(), Some("alice"))

View file

@ -20,6 +20,7 @@ impl TempHome {
let guard = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
Self { dir, _guard: guard }
}
@ -33,6 +34,7 @@ impl Drop for TempHome {
fn drop(&mut self) {
unsafe {
std::env::remove_var("ONIONWIRE_HOME");
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
}
}
@ -121,8 +123,8 @@ fn unknown_pubkey_inserts_new_row() {
fn open_at_two_homes_are_independent() {
let a = tempfile::tempdir().expect("a");
let b = tempfile::tempdir().expect("b");
let sa = onionwire::Store::open_at(a.path()).expect("open a");
let sb = onionwire::Store::open_at(b.path()).expect("open b");
let sa = onionwire::Store::open_at_with_passphrase(a.path(), "a-pass").expect("open a");
let sb = onionwire::Store::open_at_with_passphrase(b.path(), "b-pass").expect("open b");
let ia = sa.self_identity().unwrap();
let ib = sb.self_identity().unwrap();
assert_ne!(ia.identity_pk, ib.identity_pk);
@ -167,7 +169,7 @@ fn friend_prekey_lookup() {
#[test]
fn open_uses_wal_journal() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal");
}
@ -175,5 +177,87 @@ fn open_uses_wal_journal() {
fn corrupt_db_fails_closed() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("onionwire.db"), b"not a sqlite database").unwrap();
assert!(Store::open_at(dir.path()).is_err());
assert!(Store::open_at_with_passphrase(dir.path(), "onionwire-test").is_err());
}
fn home_contains_bytes(home: &Path, needle: &[u8]) -> bool {
let Ok(rd) = fs::read_dir(home) else {
return false;
};
for ent in rd.flatten() {
let name = ent.file_name();
let name = name.to_string_lossy();
if !name.starts_with("onionwire.db") {
continue;
}
let Ok(bytes) = fs::read(ent.path()) else {
continue;
};
if bytes.windows(needle.len()).any(|w| w == needle) {
return true;
}
}
false
}
#[test]
fn empty_passphrase_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let err = match Store::open_at_with_passphrase(dir.path(), "") {
Ok(_) => panic!("empty passphrase should fail"),
Err(e) => e,
};
assert!(
err.to_string().to_ascii_lowercase().contains("empty"),
"got {err}"
);
}
#[test]
fn append_message_db_file_does_not_contain_plaintext() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "correct-horse").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
let needle = b"needle-plaintext-xyzzy-at-rest";
store.append_message(&pk(1), "out", needle).unwrap();
let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].plaintext, needle);
drop(store);
assert!(
!home_contains_bytes(dir.path(), needle),
"sqlite files must not contain chat plaintext"
);
}
#[test]
fn wrong_passphrase_cannot_open_or_list() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "right-pass").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
store
.append_message(&pk(1), "in", b"secret-chat-body")
.unwrap();
drop(store);
let err = match Store::open_at_with_passphrase(dir.path(), "wrong-pass") {
Ok(_) => panic!("wrong passphrase should fail"),
Err(e) => e,
};
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("passphrase") || msg.contains("decrypt") || msg.contains("wrong"),
"got {err}"
);
}
#[test]
fn reopen_with_same_passphrase_decrypts() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
store.append_message(&pk(1), "out", b"hello again").unwrap();
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("reopen");
let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs[0].plaintext, b"hello again");
}

View file

@ -21,6 +21,9 @@ async fn alice_sends_hello_wire_bob_sqlite_has_plaintext() {
let bob_home = root.path().join("bob");
eprintln!("starting alice + bob nodes…");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
let (alice, bob) = tokio::join!(
Node::start(alice_home.clone()),
Node::start(bob_home.clone())

View file

@ -1,7 +1,10 @@
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
use onionwire::Store;
use onionwire::tui::{WipeDecision, WipeKind, WipePrompt, parse_slash, wipe_screen_text};
use onionwire::tui::{
QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text,
wipe_screen_text,
};
fn pk(tag: u8) -> [u8; 32] {
let mut k = [0u8; 32];
@ -19,7 +22,7 @@ fn db_contains(home: &std::path::Path, needle: &[u8]) -> bool {
#[test]
fn wipe_clears_messages_keeps_self_and_friends() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let me = store.self_identity().unwrap();
store
.upsert_friend(&pk(1), "a.onion", Some("alice"))
@ -51,7 +54,7 @@ fn wipe_clears_messages_keeps_self_and_friends() {
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path().join("ow");
let store = Store::open_at(&home).expect("open");
let store = Store::open_at_with_passphrase(&home, "onionwire-test").expect("open");
let old_pk = store.self_identity().unwrap().identity_pk;
store.upsert_friend(&pk(1), "a.onion", None).unwrap();
store.append_message(&pk(1), "out", b"gone").unwrap();
@ -61,7 +64,7 @@ fn wipe_all_removes_dir_so_next_open_is_new_identity() {
Store::wipe_all(&home).expect("wipe-all");
assert!(!home.exists(), "data dir gone");
let store2 = Store::open_at(&home).expect("reopen");
let store2 = Store::open_at_with_passphrase(&home, "onionwire-test").expect("reopen");
let new_pk = store2.self_identity().unwrap().identity_pk;
assert_ne!(old_pk, new_pk, "new identity key = new person");
assert_eq!(store2.friend_count().unwrap(), 0);
@ -115,3 +118,39 @@ fn wipe_prompt_text_matches_spec() {
assert!(a.contains("Type WIPEALL to confirm"));
assert!(a.contains("Esc to cancel"));
}
#[test]
fn quit_requires_clear_or_quit_then_enter() {
let mut p = QuitPrompt::new();
assert_eq!(p.on_esc(), QuitDecision::Cancel);
assert_eq!(
p.on_char('\n'),
QuitDecision::Pending,
"Enter alone must not quit"
);
for c in "CLEA".chars() {
assert_eq!(p.on_char(c), QuitDecision::Pending);
}
assert_eq!(p.on_char('R'), QuitDecision::Pending, "CLEAR without Enter");
assert_eq!(p.on_char('\n'), QuitDecision::ClearAndQuit);
let mut q = QuitPrompt::new();
for c in "QUIT".chars() {
assert_eq!(q.on_char(c), QuitDecision::Pending);
}
assert_eq!(q.on_char('\n'), QuitDecision::Quit);
}
#[test]
fn quit_prompt_text_matches_spec() {
let t = quit_screen_text();
assert!(
t.contains("message history can be cleared")
|| t.contains("Message history can be cleared")
);
assert!(t.contains("identity") && t.contains("friends"));
assert!(t.contains("not") && t.contains("/wipe-all"));
assert!(t.contains("CLEAR"));
assert!(t.contains("QUIT"));
assert!(t.contains("Esc"));
}