From e63203ff56d0ca3a93d7f6b7d9310a225dc26394 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 14:50:01 -0400 Subject: [PATCH] feat: incoming rate limit, identity backup, v0.2.0 App-level token bucket on rend accepts (30/60s, burst 10), panic-safe TUI restore, sqlite WAL + integrity_check fail-closed. Encrypted owbak1 identity backup/restore. README and threat model cover profile, XMR sidecar, backup, mixed-version frames. --- Cargo.lock | 28 +++- Cargo.toml | 4 +- README.md | 35 ++++- docs/THREAT_MODEL.md | 16 +++ src/backup.rs | 96 ++++++++++++++ src/lib.rs | 2 + src/node.rs | 93 ++++++++++++-- src/ratelimit.rs | 45 +++++++ src/store.rs | 35 +++++ src/tui.rs | 296 ++++++++++++++++++++++++++++++++++++++++++- tests/backup.rs | 135 ++++++++++++++++++++ tests/ratelimit.rs | 28 ++++ tests/store.rs | 14 ++ tests/tui_chrome.rs | 8 +- 14 files changed, 809 insertions(+), 26 deletions(-) create mode 100644 src/backup.rs create mode 100644 src/ratelimit.rs create mode 100644 tests/backup.rs create mode 100644 tests/ratelimit.rs diff --git a/Cargo.lock b/Cargo.lock index 0ada483..f57393d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -762,6 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2595,9 +2608,11 @@ dependencies = [ [[package]] name = "onionwire" -version = "0.1.2" +version = "0.2.0" dependencies = [ + "argon2", "arti-client", + "chacha20poly1305", "ed25519-dalek", "futures", "qrcode", @@ -2789,6 +2804,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index 98eb8d7..d125aee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "onionwire" -version = "0.1.2" +version = "0.2.0" edition = "2024" rust-version = "1.91" description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP." @@ -24,6 +24,8 @@ ratatui = { version = "0.30.2", default-features = false, features = ["crossterm x25519-dalek = { version = "2", features = ["static_secrets"] } snow = "0.10" serde_json = "1" +argon2 = "0.5" +chacha20poly1305 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index 1445968..4980fa5 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j | `F2` | Share: terminal QR + payload | | `F3` | Paste a friend’s payload | | `F4` | Rotate **onion** (locator only) | -| Enter | Run `/wipe` or `/wipe-all` from the composer | +| `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 | @@ -149,7 +150,37 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j ## Fail closed -If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. +If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server. + +## Profile + +`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored. + +## Monero sidecar + +OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`: + +```bash +export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083 +``` + +Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so. + +- `/pay [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`. +- `/tip [memo]` — pay the selected friend’s profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches. + +## Backup / restore + +Losing `identity_sk` loses every friend relationship. + +- `/backup /path` — type `BACKUP`, passphrase twice. Writes `owbak1` (Argon2id + ChaCha20-Poly1305). Onion is **not** in the file (locator is disposable; F4 after restore if needed). +- `/restore /path` — type `RESTORE`, passphrase. Overwrites self keys. **Does not rewrite the roster** — you may become a different person talking to old friends. + +Treat the backup file like the sqlite db. + +## Mixed versions + +0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged. ## Wipe diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 26285f8..5e01467 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -22,6 +22,22 @@ The message log, identity secret key, and friend public keys sit on disk unencry Peer onion down → send fails. Fingerprint mismatch vs the pinned key → hard fail, no send. Arti HS experimental: if it cannot publish, OnionWire stops; it does not fall back to C-tor. +## Profile is friend-visible, not a directory + +A signed `prf` frame is shown to people who already have a session with you. Apply only for existing friends. There is still no name lookup, DHT, or public profile server. Anyone who already has a Noise session can see the profile you send them; that is not confidentiality against that friend. + +## Monero sidecar is not a wallet + +OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the user’s wallet policy. + +## Backup file is the identity + +`/backup` writes the identity and Noise static secrets. Treat the file like `onionwire.db`. Restore overwrites self keys and does **not** rewrite the friends table. + +## Rate limit is availability, not anonymity + +Incoming rendezvous accepts are token-bucket limited (30/60s, burst 10). Excess is dropped without handshake. That is a DoS/availability control. It does not hide that you run an onion, and it is not a traffic-analysis defense. + ## Out of v1 Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher. diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..6270b5f --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,96 @@ +//! Encrypted identity backup. Onion (locator) is not included. + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use rand::RngCore; + +pub const MAGIC: &[u8] = b"owbak1"; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; +const PLAIN_LEN: usize = KEY_LEN * 4; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackupKeys { + pub identity_sk: [u8; 32], + pub identity_pk: [u8; 32], + pub prekey_sk: [u8; 32], + pub prekey_pk: [u8; 32], +} + +impl BackupKeys { + pub fn to_bytes(&self) -> [u8; PLAIN_LEN] { + let mut out = [0u8; PLAIN_LEN]; + out[0..32].copy_from_slice(&self.identity_sk); + out[32..64].copy_from_slice(&self.identity_pk); + out[64..96].copy_from_slice(&self.prekey_sk); + out[96..128].copy_from_slice(&self.prekey_pk); + out + } + + pub fn from_bytes(b: &[u8]) -> Result { + if b.len() != PLAIN_LEN { + return Err("backup plaintext length".into()); + } + Ok(Self { + identity_sk: b[0..32].try_into().unwrap(), + identity_pk: b[32..64].try_into().unwrap(), + prekey_sk: b[64..96].try_into().unwrap(), + prekey_pk: b[96..128].try_into().unwrap(), + }) + } +} + +fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> { + if passphrase.is_empty() { + return Err("empty passphrase".into()); + } + if salt.len() != SALT_LEN { + return Err("salt length".into()); + } + let params = Params::new(19_456, 2, 1, Some(32)).map_err(|e| e.to_string())?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| e.to_string())?; + Ok(key) +} + +pub fn seal(passphrase: &str, keys: &BackupKeys) -> Result, String> { + let mut salt = [0u8; SALT_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let key = kdf(passphrase, &salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + if nonce.len() != NONCE_LEN { + return Err("nonce length".into()); + } + let ct = cipher + .encrypt(&nonce, keys.to_bytes().as_ref()) + .map_err(|_| "encrypt failed".to_string())?; + let mut out = Vec::with_capacity(MAGIC.len() + SALT_LEN + NONCE_LEN + ct.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + Ok(out) +} + +pub fn open(passphrase: &str, blob: &[u8]) -> Result { + let min = MAGIC.len() + SALT_LEN + NONCE_LEN + 16; + if blob.len() < min || !blob.starts_with(MAGIC) { + return Err("not an onionwire backup".into()); + } + let salt = &blob[MAGIC.len()..MAGIC.len() + SALT_LEN]; + let nonce_off = MAGIC.len() + SALT_LEN; + let nonce = Nonce::from_slice(&blob[nonce_off..nonce_off + NONCE_LEN]); + let ct = &blob[nonce_off + NONCE_LEN..]; + let key = kdf(passphrase, salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let pt = cipher + .decrypt(nonce, ct) + .map_err(|_| "wrong passphrase or corrupt backup".to_string())?; + BackupKeys::from_bytes(&pt) +} diff --git a/src/lib.rs b/src/lib.rs index 90b0d32..efcab3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod backup; pub mod dispatch; pub mod frame; pub mod hs; @@ -6,6 +7,7 @@ pub mod node; pub mod pay; pub mod profile; pub mod qr; +pub mod ratelimit; pub mod session; mod store; pub mod tui; diff --git a/src/node.rs b/src/node.rs index dcb9fe4..1db302a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -16,6 +16,7 @@ use crate::loc; use crate::pay; use crate::profile; use crate::qr; +use crate::ratelimit::TokenBucket; use crate::session::{self, Keys}; use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store}; use crate::wallet::{self, Wallet}; @@ -42,8 +43,9 @@ pub struct Node { client: Client, hs: Mutex>, onion: Mutex, - keys: Keys, + keys: Mutex, wallet: Wallet, + incoming_limit: Mutex, } impl Node { @@ -69,8 +71,9 @@ impl Node { client, hs: Mutex::new(None), onion: Mutex::new(onion), - keys, + keys: Mutex::new(keys), wallet: Wallet::from_env(), + incoming_limit: Mutex::new(TokenBucket::default()), }); let rend = spawn_rend(Arc::clone(&node), rend); *node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend }); @@ -81,8 +84,15 @@ impl Node { self.onion.lock().map(|g| g.clone()).unwrap_or_default() } + fn keys(&self) -> Result { + self.keys + .lock() + .map(|g| g.clone()) + .map_err(|e| e.to_string()) + } + pub fn identity_pk(&self) -> [u8; 32] { - self.keys.identity_pk + self.keys().map(|k| k.identity_pk).unwrap_or([0; 32]) } pub fn arti_dir(&self) -> PathBuf { @@ -94,8 +104,48 @@ impl Node { } pub fn qr_payload(&self) -> Result { - qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk) - .map_err(|e| e.to_string()) + let k = self.keys()?; + qr::encode(&k.identity_sk, &self.onion(), &k.prekey_pk).map_err(|e| e.to_string()) + } + + pub fn write_backup(&self, path: &str, passphrase: &str) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let k = self.keys()?; + let blob = crate::backup::seal( + passphrase, + &crate::backup::BackupKeys { + identity_sk: k.identity_sk, + identity_pk: k.identity_pk, + prekey_sk: k.prekey_sk, + prekey_pk: k.prekey_pk, + }, + )?; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| e.to_string())?; + f.write_all(&blob).map_err(|e| e.to_string()) + } + + pub fn restore_backup(&self, path: &str, passphrase: &str) -> Result<(), String> { + let blob = std::fs::read(path).map_err(|e| e.to_string())?; + let k = crate::backup::open(passphrase, &blob)?; + self.store + .lock() + .map_err(|e| e.to_string())? + .replace_identity_keys(&k.identity_sk, &k.identity_pk, &k.prekey_sk, &k.prekey_pk) + .map_err(|e| e.to_string())?; + *self.keys.lock().map_err(|e| e.to_string())? = Keys { + identity_sk: k.identity_sk, + identity_pk: k.identity_pk, + prekey_sk: k.prekey_sk, + prekey_pk: k.prekey_pk, + }; + Ok(()) } pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> { @@ -187,8 +237,9 @@ impl Node { /// One-shot signed `prf` to a friend. Fail closed; no retry, no outbox. pub async fn push_self_profile(&self, friend_pk: &[u8]) -> Result<(), String> { let me = self.self_profile()?; + let k = self.keys()?; let prf = profile::sign( - &self.keys.identity_sk, + &k.identity_sk, &me.display_name, &me.bio, &me.xmr_addr, @@ -214,7 +265,8 @@ impl Node { memo: &str, ) -> Result<(), String> { let address = self.invoice_address().await?; - let inv = pay::sign_invoice(&self.keys.identity_sk, atomic, &address, memo, unix_now()) + let k = self.keys()?; + let inv = pay::sign_invoice(&k.identity_sk, atomic, &address, memo, unix_now()) .map_err(|e| e.to_string())?; let pt = pay::encode_invoice(&inv); self.send_once(friend_pk, &pt).await?; @@ -264,7 +316,8 @@ impl Node { .transfer(&address, amount) .await .map_err(|e| e.to_string())?; - let rcp = pay::sign_receipt(&self.keys.identity_sk, &txid, atomic, &address, unix_now()) + let k = self.keys()?; + let rcp = pay::sign_receipt(&k.identity_sk, &txid, atomic, &address, unix_now()) .map_err(|e| e.to_string())?; let pt = pay::encode_receipt(&rcp); self.send_once(friend_pk, &pt).await?; @@ -360,7 +413,8 @@ impl Node { *self.onion.lock().map_err(|e| e.to_string())? = onion.clone(); let ts = unix_now(); - let loc = loc::sign(&self.keys.identity_sk, &onion, ts).map_err(|e| e.to_string())?; + let k = self.keys()?; + let loc = loc::sign(&k.identity_sk, &onion, ts).map_err(|e| e.to_string())?; let loc_pt = loc::encode(&loc); let friends = self .store @@ -452,10 +506,10 @@ impl Node { .connect((onion, HS_PORT)) .await .map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?; - let mut sess = - session::handshake_initiator(&mut stream, &self.keys, pinned_id, remote_prekey) - .await - .map_err(session_err)?; + let keys = self.keys()?; + let mut sess = session::handshake_initiator(&mut stream, &keys, pinned_id, remote_prekey) + .await + .map_err(session_err)?; let ct = sess.encrypt(plaintext).map_err(session_err)?; frame::write_frame(&mut stream, &ct) .await @@ -468,7 +522,8 @@ impl Node { S: AsyncRead + AsyncWrite + Unpin, { let store = &self.store; - let mut sess = session::handshake_responder(stream, &self.keys, |spk| { + let keys = self.keys()?; + let mut sess = session::handshake_responder(stream, &keys, |spk| { let Ok(g) = store.lock() else { return None; }; @@ -630,6 +685,16 @@ fn spawn_rend( tokio::spawn(async move { let mut requests = std::pin::pin!(handle_rend_requests(rend)); while let Some(req) = requests.next().await { + let allow = node + .incoming_limit + .lock() + .map(|mut b| b.try_acquire()) + .unwrap_or(false); + if !allow { + eprintln!("rate-limit drop"); + drop(req); + continue; + } let serve = Arc::clone(&node); tokio::spawn(async move { let Ok(mut stream) = req.accept(Connected::new_empty()).await else { diff --git a/src/ratelimit.rs b/src/ratelimit.rs new file mode 100644 index 0000000..629f2bf --- /dev/null +++ b/src/ratelimit.rs @@ -0,0 +1,45 @@ +//! App-level token bucket for incoming rendezvous accepts. + +use std::time::{Duration, Instant}; + +pub struct TokenBucket { + rate_per_sec: f64, + burst: f64, + tokens: f64, + last: Instant, +} + +impl TokenBucket { + /// `count` tokens replenished over `window`, starting full up to `burst`. + pub fn new(count: u32, window: Duration, burst: u32) -> Self { + let secs = window.as_secs_f64().max(f64::EPSILON); + Self { + rate_per_sec: f64::from(count) / secs, + burst: f64::from(burst), + tokens: f64::from(burst), + last: Instant::now(), + } + } + + pub fn try_acquire(&mut self) -> bool { + self.try_acquire_at(Instant::now()) + } + + pub fn try_acquire_at(&mut self, now: Instant) -> bool { + let elapsed = now.saturating_duration_since(self.last).as_secs_f64(); + self.last = now; + self.tokens = (self.tokens + elapsed * self.rate_per_sec).min(self.burst); + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + +impl Default for TokenBucket { + fn default() -> Self { + Self::new(30, Duration::from_secs(60), 10) + } +} diff --git a/src/store.rs b/src/store.rs index da3f371..8838785 100644 --- a/src/store.rs +++ b/src/store.rs @@ -104,6 +104,10 @@ impl Store { mkdir_700(&home.join("arti"))?; let db_path = home.join("onionwire.db"); let conn = Connection::open(&db_path)?; + let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; + if !journal.eq_ignore_ascii_case("wal") { + return Err(Error(format!("journal_mode WAL failed: {journal}"))); + } conn.execute_batch( " PRAGMA foreign_keys = ON; @@ -164,12 +168,43 @@ impl Store { INSERT OR IGNORE INTO self_profile (id) VALUES (1); ", )?; + let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + if check != "ok" { + return Err(Error(format!("integrity_check: {check}"))); + } let store = Self { conn }; store.migrate()?; store.ensure_self()?; Ok(store) } + pub fn journal_mode(&self) -> Result { + self.conn + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .map_err(Into::into) + } + + pub fn replace_identity_keys( + &self, + identity_sk: &[u8], + identity_pk: &[u8], + prekey_sk: &[u8], + prekey_pk: &[u8], + ) -> Result<()> { + if identity_sk.len() != 32 + || identity_pk.len() != 32 + || prekey_sk.len() != 32 + || prekey_pk.len() != 32 + { + return Err(Error("identity key length".into())); + } + self.conn.execute( + "UPDATE self SET identity_sk = ?1, identity_pk = ?2, prekey_sk = ?3, prekey_pk = ?4 WHERE id = 1", + params![identity_sk, identity_pk, prekey_sk, prekey_pk], + )?; + Ok(()) + } + fn migrate(&self) -> Result<()> { self.add_column_if_missing( "friends", diff --git a/src/tui.rs b/src/tui.rs index 8466061..2460c34 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -68,6 +68,8 @@ OnionWire keys\n\ /profile edit name, bio, Monero address\n\ /pay [memo] invoice to receive\n\ /tip [memo] pay selected friend\n\ + /backup /path encrypted identity export\n\ + /restore /path overwrite self keys\n\ ? this help\n\ Ctrl-Q quit\n\ \n\ @@ -150,6 +152,8 @@ pub enum SlashCmd { Who, Pay { atomic: String, memo: String }, Tip { atomic: String, memo: String }, + Backup { path: String }, + Restore { path: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -261,9 +265,28 @@ pub fn parse_cmd(raw: &str) -> Option { { return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Tip { atomic, memo }); } + if let Some(rest) = s.strip_prefix("/backup") + && (rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return parse_path_cmd(rest).map(|path| SlashCmd::Backup { path }); + } + if let Some(rest) = s.strip_prefix("/restore") + && (rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path }); + } None } +fn parse_path_cmd(rest: &str) -> Option { + let path = rest.trim(); + if path.is_empty() { + None + } else { + Some(path.to_string()) + } +} + fn parse_amount_cmd(rest: &str) -> Option<(String, String)> { let rest = rest.trim(); if rest.is_empty() { @@ -392,11 +415,96 @@ impl RotatePrompt { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackupKind { + Backup, + Restore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackupDecision { + Pending, + Confirm, + Cancel, +} + +#[derive(Debug)] +pub struct BackupPrompt { + expected: &'static str, + buf: String, +} + +impl BackupPrompt { + pub fn backup() -> Self { + Self { + expected: "BACKUP", + buf: String::new(), + } + } + + pub fn restore() -> Self { + Self { + expected: "RESTORE", + buf: String::new(), + } + } + + pub fn on_esc(&self) -> BackupDecision { + BackupDecision::Cancel + } + + pub fn on_char(&mut self, c: char) -> BackupDecision { + if c == '\n' { + return BackupDecision::Pending; + } + if c == '\u{8}' { + self.buf.pop(); + return BackupDecision::Pending; + } + if !c.is_ascii_alphabetic() { + return BackupDecision::Pending; + } + self.buf.push(c); + if self.buf == self.expected { + BackupDecision::Confirm + } else { + BackupDecision::Pending + } + } + + pub fn typed(&self) -> &str { + &self.buf + } +} + +pub fn backup_screen_text() -> &'static str { + "Write encrypted identity backup?\n\ +This file is your identity secret. Treat it like the database.\n\ +Onion (locator) is not included.\n\ +Type BACKUP to confirm Esc to cancel" +} + +pub fn restore_screen_text() -> &'static str { + "Restore identity from backup?\n\ +This overwrites your self keys. It does not rewrite the roster.\n\ +You may become a different person with old friends.\n\ +Type RESTORE to confirm Esc to cancel" +} + +pub fn restore_terminal() { + ratatui::restore(); +} + pub fn run(node: Arc, rt: tokio::runtime::Handle) -> Result { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + prev(info); + })); let mut app = App::new(node, rt)?; let mut terminal = ratatui::init(); let result = app.run(&mut terminal); - ratatui::restore(); + restore_terminal(); result } @@ -419,12 +527,38 @@ struct App { enum Screen { Main, - Share { art: String, payload: String }, - Paste { buf: String, err: Option }, - Approve { payload: QrPayload }, - Rotate { prompt: RotatePrompt }, - Wipe { kind: WipeKind, prompt: WipePrompt }, - Profile { editor: ProfileEditor }, + Share { + art: String, + payload: String, + }, + Paste { + buf: String, + err: Option, + }, + Approve { + payload: QrPayload, + }, + Rotate { + prompt: RotatePrompt, + }, + Wipe { + kind: WipeKind, + prompt: WipePrompt, + }, + Profile { + editor: ProfileEditor, + }, + ConfirmKeys { + kind: BackupKind, + path: String, + prompt: BackupPrompt, + }, + Passphrase { + kind: BackupKind, + path: String, + first: Option, + buf: String, + }, } impl App { @@ -545,6 +679,22 @@ impl App { self.composer.clear(); tip_cmd = Some((atomic, memo)); } + Some(SlashCmd::Backup { path }) => { + self.composer.clear(); + self.screen = Screen::ConfirmKeys { + kind: BackupKind::Backup, + path, + prompt: BackupPrompt::backup(), + }; + } + Some(SlashCmd::Restore { path }) => { + self.composer.clear(); + self.screen = Screen::ConfirmKeys { + kind: BackupKind::Restore, + path, + prompt: BackupPrompt::restore(), + }; + } None => {} }, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -619,6 +769,48 @@ impl App { } _ => {} }, + Screen::ConfirmKeys { kind, path, prompt } => { + let kind = *kind; + match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if prompt.on_char(c) == BackupDecision::Confirm { + self.screen = Screen::Passphrase { + kind, + path: path.clone(), + first: None, + buf: String::new(), + }; + } + } + KeyCode::Backspace => { + prompt.on_char('\u{8}'); + } + _ => {} + } + } + Screen::Passphrase { + kind, + path, + first, + buf, + } => match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Backspace => { + buf.pop(); + } + KeyCode::Enter => { + let kind = *kind; + let path = path.clone(); + let first = first.clone(); + let pass = buf.clone(); + self.submit_passphrase(kind, &path, first.as_deref(), &pass)?; + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + buf.push(c); + } + _ => {} + }, } if let Some(kind) = wipe_confirm { return self.confirm_wipe(kind); @@ -719,6 +911,55 @@ impl App { } } + fn submit_passphrase( + &mut self, + kind: BackupKind, + path: &str, + first: Option<&str>, + pass: &str, + ) -> Result<(), String> { + match kind { + BackupKind::Backup => { + if let Some(first) = first { + if first != pass { + self.alert = Some("passphrases do not match".into()); + self.screen = Screen::Main; + return Ok(()); + } + match self.node.write_backup(path, pass) { + Ok(()) => { + self.status_note = Some(format!("backup written {path}")); + self.screen = Screen::Main; + } + Err(e) => { + self.alert = Some(e); + self.screen = Screen::Main; + } + } + } else { + self.screen = Screen::Passphrase { + kind, + path: path.to_string(), + first: Some(pass.to_string()), + buf: String::new(), + }; + } + } + BackupKind::Restore => match self.node.restore_backup(path, pass) { + Ok(()) => { + self.me_fp = to_hex(&self.node.identity_pk()); + self.status_note = Some("identity restored — roster unchanged".into()); + self.screen = Screen::Main; + } + Err(e) => { + self.alert = Some(e); + self.screen = Screen::Main; + } + }, + } + Ok(()) + } + fn confirm_rotate(&mut self) -> Result<(), String> { let node = Arc::clone(&self.node); match self.rt.block_on(node.rotate()) { @@ -855,6 +1096,10 @@ impl App { Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()), Screen::Profile { editor } => draw_profile(f, editor), + Screen::ConfirmKeys { kind, prompt, .. } => draw_confirm_keys(f, *kind, prompt.typed()), + Screen::Passphrase { + kind, first, buf, .. + } => draw_passphrase(f, *kind, first.is_some(), buf), } if self.help_open { draw_text_overlay(f, "? help", help_overlay_text()); @@ -1094,6 +1339,11 @@ fn footer_hints(screen: &Screen) -> &'static str { WipeKind::All => "type WIPEALL Esc cancel ? help", }, Screen::Profile { .. } => "Enter save Tab field Esc cancel ? help", + Screen::ConfirmKeys { kind, .. } => match kind { + BackupKind::Backup => "type BACKUP Esc cancel ? help", + BackupKind::Restore => "type RESTORE Esc cancel ? help", + }, + Screen::Passphrase { .. } => "Enter submit Esc cancel ? help", } } @@ -1194,6 +1444,38 @@ fn draw_profile(f: &mut Frame, editor: &ProfileEditor) { ); } +fn draw_confirm_keys(f: &mut Frame, kind: BackupKind, typed: &str) { + let (title, text) = match kind { + BackupKind::Backup => ("(o) backup", backup_screen_text()), + BackupKind::Restore => ("(o) restore", restore_screen_text()), + }; + let body = format!("{text}\n\n{typed}"); + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(C_TEXT)) + .wrap(Wrap { trim: false }) + .block(themed_block(title)), + f.area(), + ); +} + +fn draw_passphrase(f: &mut Frame, kind: BackupKind, second: bool, buf: &str) { + let stars: String = buf.chars().map(|_| '*').collect(); + let hint = match (kind, second) { + (BackupKind::Backup, false) => "Enter passphrase", + (BackupKind::Backup, true) => "Re-enter passphrase", + (BackupKind::Restore, _) => "Enter passphrase", + }; + let body = format!("{hint}\n\n{stars}\n\nEnter submit Esc cancel"); + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(C_TEXT)) + .wrap(Wrap { trim: false }) + .block(themed_block("(o) passphrase")), + f.area(), + ); +} + fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) { let area = f.area(); let lines = text.lines().count() as u16 + 2; diff --git a/tests/backup.rs b/tests/backup.rs new file mode 100644 index 0000000..3b83cc3 --- /dev/null +++ b/tests/backup.rs @@ -0,0 +1,135 @@ +//! Encrypted identity backup: owbak1 || salt[16] || nonce[12] || ciphertext. + +use onionwire::Store; +use onionwire::backup::{self, BackupKeys}; +use onionwire::tui::{ + BackupDecision, BackupPrompt, SlashCmd, backup_screen_text, parse_cmd, restore_screen_text, +}; + +fn keys(tag: u8) -> BackupKeys { + BackupKeys { + identity_sk: [tag; 32], + identity_pk: [tag.wrapping_add(1); 32], + prekey_sk: [tag.wrapping_add(2); 32], + prekey_pk: [tag.wrapping_add(3); 32], + } +} + +#[test] +fn roundtrip_keys() { + let k = keys(7); + let blob = backup::seal("correct horse", &k).expect("seal"); + assert!(blob.starts_with(b"owbak1"), "magic"); + assert_eq!(&blob[0..6], b"owbak1"); + let opened = backup::open("correct horse", &blob).expect("open"); + assert_eq!(opened.identity_sk, k.identity_sk); + assert_eq!(opened.identity_pk, k.identity_pk); + assert_eq!(opened.prekey_sk, k.prekey_sk); + assert_eq!(opened.prekey_pk, k.prekey_pk); +} + +#[test] +fn onion_is_not_in_backup() { + let k = keys(3); + let blob = backup::seal("pw", &k).unwrap(); + let onion = b"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; + assert!( + !blob.windows(onion.len()).any(|w| w == onion), + "locator must not be in the backup file" + ); +} + +#[test] +fn wrong_passphrase_fails() { + let blob = backup::seal("right", &keys(1)).unwrap(); + assert!(backup::open("wrong", &blob).is_err()); +} + +#[test] +fn garbage_file_fails() { + assert!(backup::open("pw", b"nope").is_err()); + assert!(backup::open("pw", b"owbak1").is_err()); + let mut blob = backup::seal("pw", &keys(2)).unwrap(); + blob.push(0xff); + assert!(backup::open("pw", &blob).is_err()); +} + +#[test] +fn restore_overwrites_self_keys_friends_stay() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = Store::open_at(dir.path()).expect("open"); + store + .upsert_friend(&[9u8; 32], "alice.onion", Some("alice")) + .unwrap(); + let old = store.self_identity().unwrap(); + let incoming = keys(42); + store + .replace_identity_keys( + &incoming.identity_sk, + &incoming.identity_pk, + &incoming.prekey_sk, + &incoming.prekey_pk, + ) + .unwrap(); + let me = store.self_identity().unwrap(); + assert_eq!(me.identity_sk, incoming.identity_sk); + assert_eq!(me.identity_pk, incoming.identity_pk); + assert_eq!(me.prekey_sk, incoming.prekey_sk); + assert_eq!(me.prekey_pk, incoming.prekey_pk); + assert_eq!(me.onion, old.onion, "onion is locator, not restored"); + assert_eq!(store.friend_count().unwrap(), 1); + let f = store.get_friend(&[9u8; 32]).unwrap().unwrap(); + assert_eq!(f.petname.as_deref(), Some("alice")); +} + +#[test] +fn slash_backup_restore() { + assert_eq!( + parse_cmd("/backup /tmp/id.owbak"), + Some(SlashCmd::Backup { + path: "/tmp/id.owbak".into() + }) + ); + assert_eq!( + parse_cmd(" /restore /tmp/id.owbak "), + Some(SlashCmd::Restore { + path: "/tmp/id.owbak".into() + }) + ); + assert_eq!(parse_cmd("/backup"), None); + assert_eq!(parse_cmd("/restore"), None); +} + +#[test] +fn backup_requires_typing_not_enter() { + let mut p = BackupPrompt::backup(); + assert_eq!(p.on_esc(), BackupDecision::Cancel); + assert_eq!(p.on_char('\n'), BackupDecision::Pending); + for c in "BACKU".chars() { + assert_eq!(p.on_char(c), BackupDecision::Pending); + } + assert_eq!(p.on_char('P'), BackupDecision::Confirm); +} + +#[test] +fn restore_requires_typing_restore() { + let mut p = BackupPrompt::restore(); + assert_eq!(p.on_char('\n'), BackupDecision::Pending); + for c in "RESTOR".chars() { + assert_eq!(p.on_char(c), BackupDecision::Pending); + } + assert_eq!(p.on_char('E'), BackupDecision::Confirm); +} + +#[test] +fn restore_screen_warns_roster_stays() { + let t = restore_screen_text(); + assert!(t.contains("does not rewrite the roster"), "{t}"); + assert!(t.contains("Type RESTORE to confirm"), "{t}"); + let b = backup_screen_text(); + assert!( + t.contains("different person") || b.contains("identity"), + "{t}\n{b}" + ); + assert!(b.contains("Type BACKUP to confirm"), "{b}"); +} diff --git a/tests/ratelimit.rs b/tests/ratelimit.rs new file mode 100644 index 0000000..444fe18 --- /dev/null +++ b/tests/ratelimit.rs @@ -0,0 +1,28 @@ +//! Incoming rend accepts: 30 / 60s, burst 10. + +use std::time::{Duration, Instant}; + +use onionwire::ratelimit::TokenBucket; + +#[test] +fn burst_allows_then_denies() { + let t0 = Instant::now(); + let mut b = TokenBucket::new(30, Duration::from_secs(60), 10); + for _ in 0..10 { + assert!(b.try_acquire_at(t0), "burst of 10 must pass"); + } + assert!(!b.try_acquire_at(t0), "11th in the burst must drop"); +} + +#[test] +fn refill_one_token_after_two_seconds() { + let t0 = Instant::now(); + let mut b = TokenBucket::new(30, Duration::from_secs(60), 10); + for _ in 0..10 { + assert!(b.try_acquire_at(t0)); + } + // 30 tokens / 60s = 0.5/s → 2s yields one token. + let t1 = t0 + Duration::from_secs(2); + assert!(b.try_acquire_at(t1)); + assert!(!b.try_acquire_at(t1)); +} diff --git a/tests/store.rs b/tests/store.rs index 2ec8164..0ba924f 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -163,3 +163,17 @@ fn friend_prekey_lookup() { assert_eq!(f.pubkey, pk(1)); assert_eq!(f.prekey, spk); } + +#[test] +fn open_uses_wal_journal() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = Store::open_at(dir.path()).expect("open"); + assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal"); +} + +#[test] +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()); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index ddff62f..8df424b 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -39,7 +39,8 @@ fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); for needle in [ - "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "Ctrl-Q", "?", + "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q", + "?", ] { assert!(help.contains(needle), "help overlay missing {needle:?}"); } @@ -156,3 +157,8 @@ fn pane_focus_cycles_roster_chat_composer() { assert_eq!(Pane::Chat.prev(), Pane::Roster); assert_eq!(Pane::Composer.prev(), Pane::Chat); } + +#[test] +fn restore_terminal_is_callable_without_panic() { + onionwire::tui::restore_terminal(); +}