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:
commit
2b42864eba
16 changed files with 461 additions and 35 deletions
13
README.md
13
README.md
|
|
@ -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 friend’s. 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 friend’s 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
23
src/main.rs
23
src/main.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
src/node.rs
14
src/node.rs
|
|
@ -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())?;
|
||||
|
|
|
|||
149
src/store.rs
149
src/store.rs
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
src/tui.rs
107
src/tui.rs
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue