diff --git a/src/lib.rs b/src/lib.rs index 565df5b..8d22191 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod frame; pub mod hs; +pub mod loc; pub mod node; pub mod qr; pub mod session; diff --git a/src/loc.rs b/src/loc.rs new file mode 100644 index 0000000..1248e25 --- /dev/null +++ b/src/loc.rs @@ -0,0 +1,102 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Loc { + pub onion: String, + pub ts: i64, + pub sig: Vec, +} + +const PREFIX: &[u8] = b"loc "; + +pub fn sign(identity_sk: &[u8], onion: &str, ts: i64) -> Result { + let sk_bytes: [u8; 32] = identity_sk + .try_into() + .map_err(|_| Error("identity secret key must be 32 bytes".into()))?; + let sk = SigningKey::from_bytes(&sk_bytes); + let sig = sk.sign(&sign_msg(onion, ts)).to_bytes().to_vec(); + Ok(Loc { + onion: onion.to_string(), + ts, + sig, + }) +} + +pub fn verify(identity_pk: &[u8], loc: &Loc) -> bool { + if identity_pk.len() != 32 || loc.sig.len() != 64 { + return false; + } + let pk: [u8; 32] = match identity_pk.try_into() { + Ok(p) => p, + Err(_) => return false, + }; + let sig: [u8; 64] = match loc.sig.as_slice().try_into() { + Ok(s) => s, + Err(_) => return false, + }; + let Ok(vk) = VerifyingKey::from_bytes(&pk) else { + return false; + }; + vk.verify(&sign_msg(&loc.onion, loc.ts), &Signature::from_bytes(&sig)) + .is_ok() +} + +pub fn encode(loc: &Loc) -> Vec { + let mut out = Vec::from(PREFIX); + out.extend_from_slice(loc.onion.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(loc.ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(to_hex(&loc.sig).as_bytes()); + out +} + +pub fn decode(pt: &[u8]) -> Option { + let rest = pt.strip_prefix(PREFIX)?; + let text = std::str::from_utf8(rest).ok()?; + let mut parts = text.splitn(3, '\n'); + let onion = parts.next()?.to_string(); + let ts = parts.next()?.parse::().ok()?; + let sig = from_hex(parts.next()?)?; + if onion.is_empty() { + return None; + } + Some(Loc { onion, ts, sig }) +} + +fn sign_msg(onion: &str, ts: i64) -> Vec { + let mut msg = Vec::from(onion.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(ts.to_string().as_bytes()); + msg +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(s: &str) -> Option> { + if s.is_empty() || !s.len().is_multiple_of(2) { + return None; + } + if !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} diff --git a/src/main.rs b/src/main.rs index 301b066..3469113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,17 @@ -fn main() { - match onionwire::Store::open() { - Ok(store) => { - if let Err(e) = onionwire::tui::run(store) { - eprintln!("onionwire: {e}"); - std::process::exit(1); - } - } - Err(e) => { - eprintln!("onionwire: {e}"); - std::process::exit(1); - } +#[tokio::main] +async fn main() { + if let Err(e) = boot().await { + eprintln!("onionwire: {e}"); + std::process::exit(1); } } + +async fn boot() -> Result<(), String> { + let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?; + eprintln!("onionwire: bootstrapping Arti…"); + let node = onionwire::node::Node::start(home).await?; + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle)) + .await + .map_err(|e| e.to_string())? +} diff --git a/src/node.rs b/src/node.rs index f43bee0..2d24bf1 100644 --- a/src/node.rs +++ b/src/node.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use futures::StreamExt; use futures::io::{AsyncRead, AsyncWrite}; @@ -11,16 +11,33 @@ use tor_hsservice::{RunningOnionService, handle_rend_requests}; use crate::frame; use crate::hs::{self, Client, HS_PORT}; +use crate::loc; use crate::qr; use crate::session::{self, Keys}; -use crate::store::{Message, Store}; +use crate::store::{Friend, Message, Store}; + +pub struct RotateResult { + pub notified: usize, + pub friends: usize, +} + +struct HsHandle { + _svc: Arc, + rend: tokio::task::JoinHandle<()>, +} + +impl Drop for HsHandle { + fn drop(&mut self) { + self.rend.abort(); + } +} pub struct Node { home: PathBuf, store: Mutex, client: Client, - _svc: Arc, - onion: String, + hs: Mutex>, + onion: Mutex, keys: Keys, } @@ -29,11 +46,12 @@ impl Node { let store = Store::open_at(&home).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())?; let state = home.join("arti"); let cache = home.join("cache"); let client = hs::bootstrapped(&state, &cache).await?; let launched = client - .launch_onion_service(hs::hs_config("onionwire")?) + .launch_onion_service(hs::hs_config(&nickname)?) .map_err(|e| format!("launch_onion_service: {e}"))? .ok_or_else(|| "onion service disabled in config — fail closed".to_string())?; let (svc, rend) = launched; @@ -44,30 +62,17 @@ impl Node { home, store: Mutex::new(store), client, - _svc: svc, - onion, + hs: Mutex::new(None), + onion: Mutex::new(onion), keys, }); - let serve = Arc::clone(&node); - tokio::spawn(async move { - let mut requests = std::pin::pin!(handle_rend_requests(rend)); - while let Some(req) = requests.next().await { - let serve = Arc::clone(&serve); - tokio::spawn(async move { - let Ok(mut stream) = req.accept(Connected::new_empty()).await else { - return; - }; - if let Err(e) = serve.handle_incoming(&mut stream).await { - eprintln!("incoming: {e}"); - } - }); - } - }); + let rend = spawn_rend(Arc::clone(&node), rend); + *node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend }); Ok(node) } - pub fn onion(&self) -> &str { - &self.onion + pub fn onion(&self) -> String { + self.onion.lock().map(|g| g.clone()).unwrap_or_default() } pub fn identity_pk(&self) -> [u8; 32] { @@ -83,12 +88,16 @@ impl Node { } pub fn qr_payload(&self) -> Result { - qr::encode(&self.keys.identity_sk, &self.onion, &self.keys.prekey_pk) + qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk) .map_err(|e| e.to_string()) } pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> { let p = qr::decode(raw).map_err(|e| e.to_string())?; + self.add_friend_payload(&p) + } + + pub fn add_friend_payload(&self, p: &qr::QrPayload) -> Result<(), String> { let store = self.store.lock().map_err(|e| e.to_string())?; store .upsert_friend(&p.pubkey, &p.onion, None) @@ -99,6 +108,31 @@ impl Node { Ok(()) } + pub fn get_friend(&self, pubkey: &[u8]) -> Result, String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .get_friend(pubkey) + .map_err(|e| e.to_string()) + } + + pub fn list_friends(&self) -> Result, String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .list_friends() + .map_err(|e| e.to_string()) + } + + pub fn friend(&self, pubkey: &[u8]) -> Result { + self.store + .lock() + .map_err(|e| e.to_string())? + .get_friend(pubkey) + .map_err(|e| e.to_string())? + .ok_or_else(|| "friend not found".into()) + } + pub fn list_messages(&self, friend_pk: &[u8]) -> Result, String> { self.store .lock() @@ -107,6 +141,72 @@ impl Node { .map_err(|e| e.to_string()) } + pub async fn connect_onion(&self, onion: &str) -> Result<(), String> { + tokio::time::timeout( + Duration::from_secs(20), + self.client.connect((onion, HS_PORT)), + ) + .await + .map_err(|_| format!("connect {onion}:{HS_PORT} timed out"))? + .map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?; + Ok(()) + } + + pub async fn rotate(self: &Arc) -> Result { + let old_nick = self + .store + .lock() + .map_err(|e| e.to_string())? + .hs_nickname() + .map_err(|e| e.to_string())?; + let new_nick = next_hs_nickname(&old_nick); + let launched = self + .client + .launch_onion_service(hs::hs_config(&new_nick)?) + .map_err(|e| format!("launch_onion_service: {e}"))? + .ok_or_else(|| "onion service disabled in config — fail closed".to_string())?; + let (svc, rend) = launched; + let onion = hs::onion_string(&svc)?; + // ponytail: hard-cut old HS before waiting; keeping both stalled ow1 at Bootstrapping. + *self.hs.lock().map_err(|e| e.to_string())? = None; + hs::wait_until_published(&svc, &onion).await?; + let rend = spawn_rend(Arc::clone(self), rend); + *self.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend }); + { + let store = self.store.lock().map_err(|e| e.to_string())?; + store.set_onion(&onion).map_err(|e| e.to_string())?; + store + .set_hs_nickname(&new_nick) + .map_err(|e| e.to_string())?; + } + *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 loc_pt = loc::encode(&loc); + let friends = self + .store + .lock() + .map_err(|e| e.to_string())? + .list_friends() + .map_err(|e| e.to_string())?; + let mut notified = 0; + for f in &friends { + if self + .push_loc(&f.pubkey, &f.onion, &f.prekey, &loc_pt) + .await + .is_ok() + { + notified += 1; + } + } + + Ok(RotateResult { + notified, + friends: friends.len(), + }) + } + pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> { let (onion, prekey) = { let store = self.store.lock().map_err(|e| e.to_string())?; @@ -139,6 +239,29 @@ impl Node { Err(last.unwrap_or_else(|| "send timed out".into())) } + async fn push_loc( + &self, + friend_pk: &[u8], + onion: &str, + prekey: &[u8], + loc_pt: &[u8], + ) -> Result<(), String> { + if prekey.len() != 32 { + return Err("friend missing prekey".into()); + } + let deadline = Instant::now() + Duration::from_secs(180); + let mut last = None::; + while Instant::now() < deadline { + match self.try_send(onion, friend_pk, prekey, loc_pt).await { + Ok(()) => return Ok(()), + Err(e) if e.contains("fingerprint mismatch") => return Err(e), + Err(e) => last = Some(e), + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + Err(last.unwrap_or_else(|| "loc push timed out".into())) + } + async fn try_send( &self, onion: &str, @@ -177,6 +300,20 @@ impl Node { .map_err(session_err)?; let ct = frame::read_frame(stream).await.map_err(|e| e.to_string())?; let pt = sess.decrypt(&ct).map_err(session_err)?; + if let Some(loc) = loc::decode(&pt) { + let applied = store.lock().map_err(|e| e.to_string())?.apply_loc( + &sess.peer_identity, + &loc.onion, + loc.ts, + &loc.sig, + ); + match applied { + Ok(true) => {} + Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"), + Err(e) => return Err(e.to_string()), + } + return Ok(()); + } store .lock() .map_err(|e| e.to_string())? @@ -186,6 +323,41 @@ impl Node { } } +fn spawn_rend( + node: Arc, + rend: impl futures::Stream + Send + 'static, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut requests = std::pin::pin!(handle_rend_requests(rend)); + while let Some(req) = requests.next().await { + let serve = Arc::clone(&node); + tokio::spawn(async move { + let Ok(mut stream) = req.accept(Connected::new_empty()).await else { + return; + }; + if let Err(e) = serve.handle_incoming(&mut stream).await { + eprintln!("incoming: {e}"); + } + }); + } + }) +} + +fn next_hs_nickname(cur: &str) -> String { + let n = cur + .strip_prefix("ow") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + format!("ow{}", n.saturating_add(1)) +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + fn session_err(e: session::Error) -> String { if e.is_fingerprint_mismatch() { crate::tui::fingerprint_mismatch_banner().to_string() diff --git a/src/store.rs b/src/store.rs index ebdc7fe..75e13cb 100644 --- a/src/store.rs +++ b/src/store.rs @@ -65,6 +65,10 @@ impl Store { Self::open_at(&home_dir()?) } + pub fn home_dir() -> Result { + home_dir() + } + pub fn open_at(home: &Path) -> Result { mkdir_700(home)?; mkdir_700(&home.join("arti"))?; @@ -98,7 +102,8 @@ impl Store { onion TEXT NOT NULL, onion_rotated_at INTEGER NOT NULL, prekey_sk BLOB NOT NULL, - prekey_pk BLOB NOT NULL + prekey_pk BLOB NOT NULL, + hs_nickname TEXT NOT NULL DEFAULT 'ow0' ); ", )?; @@ -129,6 +134,11 @@ impl Store { "prekey_pk", "ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''", )?; + self.add_column_if_missing( + "self", + "hs_nickname", + "ALTER TABLE self ADD COLUMN hs_nickname TEXT NOT NULL DEFAULT 'ow0'", + )?; Ok(()) } @@ -154,8 +164,8 @@ impl Store { let pk = signing.verifying_key().to_bytes().to_vec(); let (psk, ppk) = gen_prekey(); self.conn.execute( - "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk) - VALUES (1, ?1, ?2, '', 0, ?3, ?4)", + "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk, hs_nickname) + VALUES (1, ?1, ?2, '', 0, ?3, ?4, 'ow0')", params![sk, pk, psk, ppk], )?; } else { @@ -238,6 +248,22 @@ impl Store { Ok(out) } + pub fn hs_nickname(&self) -> Result { + self.conn + .query_row("SELECT hs_nickname FROM self WHERE id = 1", [], |row| { + row.get(0) + }) + .map_err(Into::into) + } + + pub fn set_hs_nickname(&self, nickname: &str) -> Result<()> { + self.conn.execute( + "UPDATE self SET hs_nickname = ?1 WHERE id = 1", + params![nickname], + )?; + Ok(()) + } + pub fn set_onion(&self, onion: &str) -> Result<()> { let now = unix_now(); self.conn.execute( @@ -247,6 +273,37 @@ impl Store { Ok(()) } + /// Apply a signed locator update. Bad sig / stale ts / unknown friend → no change. + pub fn apply_loc(&self, pubkey: &[u8], onion: &str, ts: i64, sig: &[u8]) -> Result { + let loc = crate::loc::Loc { + onion: onion.to_string(), + ts, + sig: sig.to_vec(), + }; + if !crate::loc::verify(pubkey, &loc) { + return Ok(false); + } + let Some(updated_at) = self + .conn + .query_row( + "SELECT onion_updated_at FROM friends WHERE pubkey = ?1", + params![pubkey], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(false); + }; + if ts <= updated_at { + return Ok(false); + } + let n = self.conn.execute( + "UPDATE friends SET onion = ?1, onion_updated_at = ?2 WHERE pubkey = ?3", + params![onion, ts, pubkey], + )?; + Ok(n > 0) + } + pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> { let n = self.conn.execute( "UPDATE friends SET prekey = ?1 WHERE pubkey = ?2", diff --git a/src/tui.rs b/src/tui.rs index 6edaccb..3b2026b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,5 @@ use std::io; +use std::sync::Arc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Layout}; @@ -6,15 +7,69 @@ use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::{DefaultTerminal, Frame}; +use crate::node::Node; use crate::qr::{self, QrPayload}; -use crate::store::{Friend, Store}; +use crate::store::Friend; pub fn fingerprint_mismatch_banner() -> &'static str { "fingerprint mismatch" } -pub fn run(store: Store) -> Result<(), String> { - let mut app = App::new(store)?; +pub fn rotate_screen_text() -> &'static str { + "Rotate onion address?\n\ +Your identity key stays the same.\n\ +Online friends get a signed location update.\n\ +Offline friends CANNOT find you until they rescan your QR.\n\ +Type ROTATE to confirm Esc to cancel" +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RotateDecision { + Pending, + Confirm, + Cancel, +} + +#[derive(Debug, Default)] +pub struct RotatePrompt { + buf: String, +} + +impl RotatePrompt { + pub fn new() -> Self { + Self::default() + } + + pub fn on_esc(&self) -> RotateDecision { + RotateDecision::Cancel + } + + pub fn on_char(&mut self, c: char) -> RotateDecision { + if c == '\n' { + return RotateDecision::Pending; + } + if c == '\u{8}' { + self.buf.pop(); + return RotateDecision::Pending; + } + if !c.is_ascii_alphabetic() { + return RotateDecision::Pending; + } + self.buf.push(c); + if self.buf == "ROTATE" { + RotateDecision::Confirm + } else { + RotateDecision::Pending + } + } + + pub fn typed(&self) -> &str { + &self.buf + } +} + +pub fn run(node: Arc, rt: tokio::runtime::Handle) -> Result<(), String> { + let mut app = App::new(node, rt)?; let mut terminal = ratatui::init(); let result = app.run(&mut terminal); ratatui::restore(); @@ -22,15 +77,15 @@ pub fn run(store: Store) -> Result<(), String> { } struct App { - store: Store, + node: Arc, + rt: tokio::runtime::Handle, screen: Screen, friends: Vec, selected: usize, me_fp: String, me_onion: String, - identity_sk: Vec, - prekey_pk: Vec, alert: Option, + status_note: Option, } enum Screen { @@ -38,23 +93,22 @@ enum Screen { Share { art: String, payload: String }, Paste { buf: String, err: Option }, Approve { payload: QrPayload }, - Rotate, + Rotate { prompt: RotatePrompt }, } impl App { - fn new(store: Store) -> Result { - let me = store.self_identity().map_err(|e| e.to_string())?; - let friends = store.list_friends().map_err(|e| e.to_string())?; + fn new(node: Arc, rt: tokio::runtime::Handle) -> Result { + let friends = node.list_friends()?; Ok(Self { - store, + me_fp: to_hex(&node.identity_pk()), + me_onion: node.onion(), + node, + rt, screen: Screen::Main, friends, selected: 0, - me_fp: to_hex(&me.identity_pk), - me_onion: me.onion, - identity_sk: me.identity_sk, - prekey_pk: me.prekey_pk, alert: None, + status_note: None, }) } @@ -92,7 +146,11 @@ impl App { err: None, }; } - KeyCode::F(4) => self.screen = Screen::Rotate, + KeyCode::F(4) => { + self.screen = Screen::Rotate { + prompt: RotatePrompt::new(), + }; + } _ => {} }, Screen::Share { .. } => { @@ -124,23 +182,43 @@ impl App { KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main, _ => {} }, - Screen::Rotate => { - if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { - self.screen = Screen::Main; + Screen::Rotate { prompt } => match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if prompt.on_char(c) == RotateDecision::Confirm { + self.confirm_rotate()?; + } } - } + KeyCode::Backspace => { + prompt.on_char('\u{8}'); + } + _ => {} + }, } Ok(()) } + fn confirm_rotate(&mut self) -> Result<(), String> { + let node = Arc::clone(&self.node); + match self.rt.block_on(node.rotate()) { + Ok(outcome) => { + self.me_onion = node.onion(); + self.status_note = Some(format!( + "rotated · notified {}/{} friends", + outcome.notified, outcome.friends + )); + self.open_share() + } + Err(e) => { + self.alert = Some(e); + self.screen = Screen::Main; + Ok(()) + } + } + } + fn open_share(&mut self) -> Result<(), String> { - let onion = if self.me_onion.is_empty() { - "unset" - } else { - self.me_onion.as_str() - }; - let payload = - qr::encode(&self.identity_sk, onion, &self.prekey_pk).map_err(|e| e.to_string())?; + let payload = self.node.qr_payload()?; let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?; self.screen = Screen::Share { art, payload }; Ok(()) @@ -155,11 +233,7 @@ impl App { }; } Ok(payload) => { - let known = self - .store - .get_friend(&payload.pubkey) - .map_err(|e| e.to_string())? - .is_some(); + let known = self.node.get_friend(&payload.pubkey)?.is_some(); if known { self.accept_friend(&payload)?; } else { @@ -171,19 +245,14 @@ impl App { } fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> { - self.store - .upsert_friend(&payload.pubkey, &payload.onion, None) - .map_err(|e| e.to_string())?; - self.store - .set_friend_prekey(&payload.pubkey, &payload.signed_prekey) - .map_err(|e| e.to_string())?; + self.node.add_friend_payload(payload)?; self.reload_friends()?; self.screen = Screen::Main; Ok(()) } fn reload_friends(&mut self) -> Result<(), String> { - self.friends = self.store.list_friends().map_err(|e| e.to_string())?; + self.friends = self.node.list_friends()?; if self.friends.is_empty() { self.selected = 0; } else { @@ -198,7 +267,7 @@ impl App { Screen::Share { art, payload } => draw_share(f, art, payload), Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()), Screen::Approve { payload } => draw_approve(f, payload), - Screen::Rotate => draw_rotate(f), + Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), } } @@ -250,8 +319,13 @@ impl App { } else { self.me_onion.as_str() }; + let note = self + .status_note + .as_deref() + .map(|s| format!("{s} | ")) + .unwrap_or_default(); let status = format!( - "TOR: offline | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit", + "{note}TOR: up | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit", truncate_fp(&self.me_fp), onion ); @@ -293,12 +367,8 @@ fn draw_approve(f: &mut Frame, payload: &QrPayload) { ); } -fn draw_rotate(f: &mut Frame) { - let body = "Rotate onion address?\n\ -Your identity key stays the same.\n\ -Online friends get a signed location update.\n\ -Offline friends CANNOT find you until they rescan your QR.\n\n\ -M4 will rotate the HS. Esc / Enter to go back (no change)."; +fn draw_rotate(f: &mut Frame, typed: &str) { + let body = format!("{}\n\n{typed}", rotate_screen_text()); f.render_widget( Paragraph::new(body) .wrap(Wrap { trim: false }) diff --git a/tests/rotate.rs b/tests/rotate.rs new file mode 100644 index 0000000..d009a26 --- /dev/null +++ b/tests/rotate.rs @@ -0,0 +1,132 @@ +//! M4: loc frames, F4 confirm typing, identity stays put. + +use ed25519_dalek::SigningKey; +use onionwire::Store; +use onionwire::loc::{self, Loc}; +use onionwire::tui::{RotateDecision, RotatePrompt}; +use rand::rngs::OsRng; + +fn pk(tag: u8) -> [u8; 32] { + let mut k = [0u8; 32]; + k[0] = tag; + k +} + +fn store() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = Store::open_at(dir.path()).expect("open"); + (dir, store) +} + +#[test] +fn signed_loc_updates_friend_onion_keeps_fingerprint() { + let (_dir, store) = store(); + let sk = SigningKey::generate(&mut OsRng); + let pk = sk.verifying_key().to_bytes(); + store + .upsert_friend(&pk, "old.onion", Some("alice")) + .unwrap(); + store.set_friend_prekey(&pk, &[1u8; 32]).unwrap(); + let before = store.get_friend(&pk).unwrap().unwrap(); + + let ts = 2_000_000_000; + let loc = loc::sign(&sk.to_bytes(), "new.onion", ts).expect("sign"); + assert!( + store.apply_loc(&pk, &loc.onion, loc.ts, &loc.sig).unwrap(), + "newer signed loc must apply" + ); + + let after = store.get_friend(&pk).unwrap().unwrap(); + assert_eq!(after.onion, "new.onion"); + assert_eq!(after.fingerprint, before.fingerprint); + assert_eq!(after.petname.as_deref(), Some("alice")); + assert_eq!(store.friend_count().unwrap(), 1); +} + +#[test] +fn unsigned_loc_does_not_change_row() { + let (_dir, store) = store(); + store.upsert_friend(&pk(1), "old.onion", Some("c")).unwrap(); + let loc = Loc { + onion: "evil.onion".into(), + ts: 2_000_000_000, + sig: vec![], + }; + assert!( + !store + .apply_loc(&pk(1), &loc.onion, loc.ts, &loc.sig) + .unwrap() + ); + assert_eq!( + store.get_friend(&pk(1)).unwrap().unwrap().onion, + "old.onion" + ); +} + +#[test] +fn wrong_key_loc_does_not_change_row() { + let (_dir, store) = store(); + let owner = SigningKey::generate(&mut OsRng); + let owner_pk = owner.verifying_key().to_bytes(); + store.upsert_friend(&owner_pk, "old.onion", None).unwrap(); + let other = SigningKey::generate(&mut OsRng); + let loc = loc::sign(&other.to_bytes(), "hijack.onion", 2_000_000_000).unwrap(); + assert!( + !store + .apply_loc(&owner_pk, &loc.onion, loc.ts, &loc.sig) + .unwrap() + ); + assert_eq!( + store.get_friend(&owner_pk).unwrap().unwrap().onion, + "old.onion" + ); +} + +#[test] +fn stale_ts_loc_does_not_change_row() { + let (_dir, store) = store(); + let sk = SigningKey::generate(&mut OsRng); + let pk = sk.verifying_key().to_bytes(); + store.upsert_friend(&pk, "old.onion", None).unwrap(); + let loc = loc::sign(&sk.to_bytes(), "new.onion", 1).unwrap(); + assert!(!store.apply_loc(&pk, &loc.onion, loc.ts, &loc.sig).unwrap()); + assert_eq!(store.get_friend(&pk).unwrap().unwrap().onion, "old.onion"); +} + +#[test] +fn loc_frame_roundtrip_and_chat_is_not_loc() { + let loc = loc::sign(&[7u8; 32], "abc.onion", 42).unwrap(); + let bytes = loc::encode(&loc); + let parsed = loc::decode(&bytes).expect("loc frame"); + assert_eq!(parsed.onion, "abc.onion"); + assert_eq!(parsed.ts, 42); + assert_eq!(parsed.sig, loc.sig); + assert!(loc::decode(b"hello wire").is_none()); +} + +#[test] +fn f4_requires_typing_rotate_not_enter() { + let mut p = RotatePrompt::new(); + assert_eq!(p.on_esc(), RotateDecision::Cancel); + assert_eq!( + p.on_char('\n'), + RotateDecision::Pending, + "Enter must not rotate" + ); + for c in "ROTA".chars() { + assert_eq!(p.on_char(c), RotateDecision::Pending); + } + assert_eq!(p.on_char('T'), RotateDecision::Pending); + assert_eq!(p.on_char('E'), RotateDecision::Confirm); +} + +#[test] +fn rotate_prompt_text_matches_spec() { + let t = onionwire::tui::rotate_screen_text(); + assert!(t.contains("Rotate onion address?")); + assert!(t.contains("Your identity key stays the same.")); + assert!(t.contains("Online friends get a signed location update.")); + assert!(t.contains("Offline friends CANNOT find you until they rescan your QR.")); + assert!(t.contains("Type ROTATE to confirm")); + assert!(t.contains("Esc to cancel")); +} diff --git a/tests/rotate_hs.rs b/tests/rotate_hs.rs new file mode 100644 index 0000000..cbb7b8d --- /dev/null +++ b/tests/rotate_hs.rs @@ -0,0 +1,119 @@ +//! M4 live: F4 rotate onion locator; identity stays; online peer updates. + +use std::time::{Duration, Instant}; + +use onionwire::Store; +use onionwire::node::Node; + +const AFTER: &[u8] = b"after rotate"; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs live Tor network"] +async fn rotate_changes_onion_not_identity_and_notifies_online_peer() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with_test_writer() + .try_init(); + + let root = tempfile::tempdir().expect("tempdir"); + let alice_home = root.path().join("alice"); + let bob_home = root.path().join("bob"); + let carol_home = root.path().join("carol"); + + eprintln!("starting alice + bob…"); + let (alice, bob) = tokio::join!( + Node::start(alice_home.clone()), + Node::start(bob_home.clone()) + ); + let alice = alice.expect("alice"); + let bob = bob.expect("bob"); + + let a_qr = alice.qr_payload().expect("alice qr"); + let b_qr = bob.qr_payload().expect("bob qr"); + 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 a_pk = alice.identity_pk(); + carol + .upsert_friend(&a_pk, &alice.onion(), Some("alice")) + .expect("carol knows alice"); + carol + .set_friend_prekey(&a_pk, &[0u8; 32]) + .expect("carol prekey placeholder"); + + let fp_before = alice.identity_pk(); + let onion_before = alice.onion(); + eprintln!("alice onion before={onion_before}"); + + let outcome = alice.rotate().await.expect("rotate"); + let onion_after = alice.onion(); + eprintln!( + "alice onion after={onion_after} notified={}/{}", + outcome.notified, outcome.friends + ); + + assert_eq!(alice.identity_pk(), fp_before, "identity key never changes"); + assert_ne!(onion_after, onion_before, "onion locator must change"); + assert_eq!(outcome.friends, 1, "only bob is a friend"); + assert_eq!(outcome.notified, 1, "online bob got loc"); + + let deadline = Instant::now() + Duration::from_secs(30); + let bob_row = loop { + let row = bob.friend(&fp_before).expect("bob row"); + if row.onion == onion_after { + break row; + } + if Instant::now() >= deadline { + panic!( + "bob locator not updated; have {} want {onion_after}", + row.onion + ); + } + tokio::time::sleep(Duration::from_millis(200)).await; + }; + assert_eq!(bob_row.pubkey, fp_before); + assert_eq!(bob_row.fingerprint, to_hex(&fp_before)); + + bob.send(&fp_before, AFTER) + .await + .expect("bob dials new locator"); + + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let msgs = alice.list_messages(&bob.identity_pk()).expect("alice msgs"); + if msgs.iter().any(|m| m.dir == "in" && m.plaintext == AFTER) { + break; + } + if Instant::now() >= deadline { + panic!("alice missing post-rotate message; got {msgs:?}"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + assert!( + alice.connect_onion(&onion_before).await.is_err(), + "old HS must be hard-cut" + ); + + let c_row = carol.get_friend(&a_pk).unwrap().unwrap(); + assert_eq!(c_row.onion, onion_before, "offline carol stays stale"); + assert_eq!(c_row.petname.as_deref(), Some("alice")); + + let new_qr = alice.qr_payload().expect("new qr"); + let p = onionwire::qr::decode(&new_qr).expect("decode"); + carol + .upsert_friend(&p.pubkey, &p.onion, None) + .expect("rescan"); + assert_eq!(carol.friend_count().unwrap(), 1); + let c2 = carol.get_friend(&a_pk).unwrap().unwrap(); + assert_eq!(c2.onion, onion_after); + assert_eq!(c2.petname.as_deref(), Some("alice")); + assert_eq!(c2.fingerprint, c_row.fingerprint); +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +}