diff --git a/src/lib.rs b/src/lib.rs index c560e94..1c01562 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,10 @@ pub mod frame; pub mod hs; pub mod loc; pub mod node; +pub mod profile; pub mod qr; pub mod session; mod store; pub mod tui; -pub use store::{Friend, Message, SelfIdentity, Store}; +pub use store::{Friend, FriendProfile, Message, SelfIdentity, Store}; diff --git a/src/node.rs b/src/node.rs index 8a11dfe..288ea48 100644 --- a/src/node.rs +++ b/src/node.rs @@ -13,9 +13,10 @@ use crate::dispatch::{self, Kind}; use crate::frame; use crate::hs::{self, Client, HS_PORT}; use crate::loc; +use crate::profile; use crate::qr; use crate::session::{self, Keys}; -use crate::store::{Friend, Message, Store}; +use crate::store::{Friend, FriendProfile, Message, Store}; pub struct RotateResult { pub notified: usize, @@ -150,6 +151,57 @@ impl Node { .map_err(|e| e.to_string()) } + pub fn self_profile(&self) -> Result { + self.store + .lock() + .map_err(|e| e.to_string())? + .self_profile() + .map_err(|e| e.to_string()) + } + + pub fn friend_profile(&self, pubkey: &[u8]) -> Result, String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .friend_profile(pubkey) + .map_err(|e| e.to_string()) + } + + pub fn set_self_profile( + &self, + display_name: &str, + bio: &str, + xmr_addr: &str, + ) -> Result<(), String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .set_self_profile(display_name, bio, xmr_addr) + .map_err(|e| e.to_string()) + } + + /// 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 prf = profile::sign( + &self.keys.identity_sk, + &me.display_name, + &me.bio, + &me.xmr_addr, + me.updated_at, + ) + .map_err(|e| e.to_string())?; + let pt = profile::encode(&prf); + let (onion, prekey) = { + let f = self.friend(friend_pk)?; + if f.prekey.len() != 32 { + return Err("friend missing prekey".into()); + } + (f.onion, f.prekey) + }; + self.try_send(&onion, friend_pk, &prekey, &pt).await + } + pub async fn connect_onion(&self, onion: &str) -> Result<(), String> { tokio::time::timeout( Duration::from_secs(20), @@ -320,7 +372,25 @@ impl Node { ); match applied { Ok(true) => {} - Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"), + Ok(false) => { + eprintln!("loc dropped (bad sig, stale ts, or unknown friend)") + } + Err(e) => return Err(e.to_string()), + } + } + Ok(()) + } + Kind::Profile => { + if let Some(prf) = profile::decode(&pt) { + let applied = store + .lock() + .map_err(|e| e.to_string())? + .apply_profile(&sess.peer_identity, &prf); + match applied { + Ok(true) => {} + Ok(false) => { + eprintln!("prf dropped (bad sig, stale ts, or unknown friend)") + } Err(e) => return Err(e.to_string()), } } @@ -334,7 +404,7 @@ impl Node { .map_err(|e| e.to_string())?; Ok(()) } - Kind::Profile | Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()), + Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()), } } } diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..38228cc --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,157 @@ +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 Profile { + pub display_name: String, + pub bio: String, + pub xmr_addr: String, + pub ts: i64, + pub sig: Vec, +} + +const PREFIX: &[u8] = b"prf "; +const NAME_MAX: usize = 64; +const BIO_MAX: usize = 512; + +pub fn validate(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { + check_fields(display_name, bio, xmr_addr) +} + +pub fn sign( + identity_sk: &[u8], + display_name: &str, + bio: &str, + xmr_addr: &str, + ts: i64, +) -> Result { + check_fields(display_name, bio, xmr_addr)?; + 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(display_name, bio, xmr_addr, ts)) + .to_bytes() + .to_vec(); + Ok(Profile { + display_name: display_name.to_string(), + bio: bio.to_string(), + xmr_addr: xmr_addr.to_string(), + ts, + sig, + }) +} + +pub fn verify(identity_pk: &[u8], prf: &Profile) -> bool { + if check_fields(&prf.display_name, &prf.bio, &prf.xmr_addr).is_err() { + return false; + } + if identity_pk.len() != 32 || prf.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 prf.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(&prf.display_name, &prf.bio, &prf.xmr_addr, prf.ts), + &Signature::from_bytes(&sig), + ) + .is_ok() +} + +pub fn encode(prf: &Profile) -> Vec { + let mut out = Vec::from(PREFIX); + out.extend_from_slice(prf.display_name.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(prf.bio.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(prf.xmr_addr.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(prf.ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(to_hex(&prf.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(5, '\n'); + let display_name = parts.next()?.to_string(); + let bio = parts.next()?.to_string(); + let xmr_addr = parts.next()?.to_string(); + let ts = parts.next()?.parse::().ok()?; + let sig = from_hex(parts.next()?)?; + if check_fields(&display_name, &bio, &xmr_addr).is_err() { + return None; + } + Some(Profile { + display_name, + bio, + xmr_addr, + ts, + sig, + }) +} + +fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { + if display_name.len() > NAME_MAX { + return Err(Error("display_name longer than 64 bytes".into())); + } + if bio.len() > BIO_MAX { + return Err(Error("bio longer than 512 bytes".into())); + } + if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') { + return Err(Error("profile fields must not contain newlines".into())); + } + Ok(()) +} + +fn sign_msg(display_name: &str, bio: &str, xmr_addr: &str, ts: i64) -> Vec { + let mut msg = Vec::from(display_name.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(bio.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(xmr_addr.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/store.rs b/src/store.rs index 09dc832..7391164 100644 --- a/src/store.rs +++ b/src/store.rs @@ -54,6 +54,13 @@ pub struct Friend { pub prekey: Vec, } +pub struct FriendProfile { + pub display_name: String, + pub bio: String, + pub xmr_addr: String, + pub updated_at: i64, +} + #[derive(Debug)] pub struct Message { pub dir: String, @@ -105,6 +112,21 @@ impl Store { prekey_pk BLOB NOT NULL, hs_nickname TEXT NOT NULL DEFAULT 'ow0' ); + CREATE TABLE IF NOT EXISTS self_profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + display_name TEXT NOT NULL DEFAULT '', + bio TEXT NOT NULL DEFAULT '', + xmr_addr TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS friend_profiles ( + pubkey BLOB PRIMARY KEY, + display_name TEXT NOT NULL, + bio TEXT NOT NULL, + xmr_addr TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ); + INSERT OR IGNORE INTO self_profile (id) VALUES (1); ", )?; let store = Self { conn }; @@ -304,6 +326,79 @@ impl Store { Ok(n > 0) } + pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { + crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?; + let prev: i64 = self.conn.query_row( + "SELECT updated_at FROM self_profile WHERE id = 1", + [], + |row| row.get(0), + )?; + let now = unix_now().max(prev + 1); + self.conn.execute( + "UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1", + params![display_name, bio, xmr_addr, now], + )?; + Ok(()) + } + + pub fn self_profile(&self) -> Result { + self.conn + .query_row( + "SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1", + [], + profile_from_row, + ) + .map_err(Into::into) + } + + /// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert. + pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result { + if !crate::profile::verify(pubkey, prf) { + return Ok(false); + } + let exists: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM friends WHERE pubkey = ?1", + params![pubkey], + |row| row.get(0), + )?; + if exists == 0 { + return Ok(false); + } + let prev: Option = self + .conn + .query_row( + "SELECT updated_at FROM friend_profiles WHERE pubkey = ?1", + params![pubkey], + |row| row.get(0), + ) + .optional()?; + if prev.is_some_and(|t| prf.ts <= t) { + return Ok(false); + } + self.conn.execute( + "INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(pubkey) DO UPDATE SET + display_name = excluded.display_name, + bio = excluded.bio, + xmr_addr = excluded.xmr_addr, + updated_at = excluded.updated_at", + params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts], + )?; + Ok(true) + } + + pub fn friend_profile(&self, pubkey: &[u8]) -> Result> { + self.conn + .query_row( + "SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1", + params![pubkey], + profile_from_row, + ) + .optional() + .map_err(Into::into) + } + pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> { let n = self.conn.execute( "UPDATE friends SET prekey = ?1 WHERE pubkey = ?2", @@ -385,6 +480,15 @@ impl Store { } } +fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(FriendProfile { + display_name: row.get(0)?, + bio: row.get(1)?, + xmr_addr: row.get(2)?, + updated_at: row.get(3)?, + }) +} + fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Friend { pubkey: row.get(0)?, diff --git a/src/tui.rs b/src/tui.rs index 437e544..c6535a4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -9,7 +9,7 @@ use ratatui::{DefaultTerminal, Frame}; use crate::node::Node; use crate::qr::{self, QrPayload}; -use crate::store::Friend; +use crate::store::{Friend, FriendProfile}; const C_ACCENT: Color = Color::Rgb(232, 165, 75); const C_TEXT: Color = Color::Rgb(196, 214, 176); @@ -59,17 +59,47 @@ OnionWire keys\n\ 1 / 2 / 3 focus roster / chat / composer\n\ j k or Up Down move or scroll focused pane\n\ g / G jump to top / bottom\n\ - Enter /wipe /wipe-all from composer\n\ + Enter /wipe /wipe-all /profile /who\n\ Esc close overlay, go back, clear composer\n\ F2 share QR\n\ F3 paste a friend QR\n\ F4 rotate onion locator\n\ + F5 selected friend's profile (/who)\n\ + /profile edit name, bio, Monero address\n\ ? this help\n\ Ctrl-Q quit\n\ \n\ Esc closes this overlay" } +pub fn who_overlay_text(fingerprint: &str, onion: &str, profile: Option<&FriendProfile>) -> String { + match profile { + None => format!( + "friend profile\n\nfp {fingerprint}\nonion {onion}\n\nno profile yet\n\nEsc closes" + ), + Some(p) => { + let name = if p.display_name.is_empty() { + "(none)" + } else { + p.display_name.as_str() + }; + let bio = if p.bio.is_empty() { + "(none)" + } else { + p.bio.as_str() + }; + let xmr = if p.xmr_addr.is_empty() { + "(none)" + } else { + p.xmr_addr.as_str() + }; + format!( + "friend profile\n\nname {name}\nbio {bio}\nxmr {xmr}\nfp {fingerprint}\nonion {onion}\n\nEsc closes" + ) + } + } +} + pub fn banner_for_width(width: u16) -> &'static str { let full = wordmark_banner(); let max_line = full.lines().map(|l| l.chars().count()).max().unwrap_or(0); @@ -111,6 +141,90 @@ pub enum WipeKind { All, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlashCmd { + Wipe(WipeKind), + Profile, + Who, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileDraft { + pub display_name: String, + pub bio: String, + pub xmr_addr: String, +} + +#[derive(Debug, Clone)] +pub struct ProfileEditor { + name: String, + bio: String, + xmr: String, + field: usize, +} + +impl ProfileEditor { + pub fn new(display_name: &str, bio: &str, xmr_addr: &str) -> Self { + Self { + name: display_name.to_string(), + bio: bio.to_string(), + xmr: xmr_addr.to_string(), + field: 0, + } + } + + pub fn on_enter(&self) -> ProfileDraft { + ProfileDraft { + display_name: self.name.clone(), + bio: self.bio.clone(), + xmr_addr: self.xmr.clone(), + } + } + + pub fn on_tab(&mut self) { + self.field = (self.field + 1) % 3; + } + + pub fn on_backtab(&mut self) { + self.field = (self.field + 2) % 3; + } + + pub fn on_char(&mut self, c: char) { + if c == '\n' { + return; + } + self.field_mut().push(c); + } + + pub fn on_backspace(&mut self) { + self.field_mut().pop(); + } + + pub fn field(&self) -> usize { + self.field + } + + pub fn display_name(&self) -> &str { + &self.name + } + + pub fn bio(&self) -> &str { + &self.bio + } + + pub fn xmr_addr(&self) -> &str { + &self.xmr + } + + fn field_mut(&mut self) -> &mut String { + match self.field { + 0 => &mut self.name, + 1 => &mut self.bio, + _ => &mut self.xmr, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WipeDecision { Pending, @@ -124,10 +238,19 @@ pub enum AppExit { WipeAll, } -pub fn parse_slash(raw: &str) -> Option { +pub fn parse_cmd(raw: &str) -> Option { match raw.trim() { - "/wipe" => Some(WipeKind::Messages), - "/wipe-all" => Some(WipeKind::All), + "/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)), + "/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)), + "/profile" => Some(SlashCmd::Profile), + "/who" => Some(SlashCmd::Who), + _ => None, + } +} + +pub fn parse_slash(raw: &str) -> Option { + match parse_cmd(raw) { + Some(SlashCmd::Wipe(k)) => Some(k), _ => None, } } @@ -262,6 +385,7 @@ struct App { composer: String, focus: Pane, help_open: bool, + who_open: bool, chat_scroll: u16, } @@ -272,6 +396,7 @@ enum Screen { Approve { payload: QrPayload }, Rotate { prompt: RotatePrompt }, Wipe { kind: WipeKind, prompt: WipePrompt }, + Profile { editor: ProfileEditor }, } impl App { @@ -290,6 +415,7 @@ impl App { composer: String::new(), focus: Pane::Composer, help_open: false, + who_open: false, chat_scroll: 0, }) } @@ -320,12 +446,25 @@ impl App { } return Ok(None); } + if self.who_open { + match key.code { + KeyCode::Esc | KeyCode::F(5) => self.who_open = false, + KeyCode::Char('?') => { + self.who_open = false; + self.help_open = true; + } + _ => {} + } + return Ok(None); + } if key.code == KeyCode::Char('?') && !matches!(self.screen, Screen::Paste { .. }) { self.help_open = true; return Ok(None); } let mut wipe_confirm = None; + let mut profile_save = None; + let mut open_profile = false; match &mut self.screen { Screen::Main => match key.code { KeyCode::Tab => self.focus = self.focus.next(), @@ -344,14 +483,15 @@ impl App { prompt: RotatePrompt::new(), }; } + KeyCode::F(5) => self.who_open = true, KeyCode::Esc => self.composer.clear(), KeyCode::Backspace => { if self.focus == Pane::Composer { self.composer.pop(); } } - KeyCode::Enter => { - if let Some(kind) = parse_slash(&self.composer) { + KeyCode::Enter => match parse_cmd(&self.composer) { + Some(SlashCmd::Wipe(kind)) => { self.composer.clear(); let prompt = match kind { WipeKind::Messages => WipePrompt::messages(), @@ -359,7 +499,16 @@ impl App { }; self.screen = Screen::Wipe { kind, prompt }; } - } + Some(SlashCmd::Profile) => { + self.composer.clear(); + open_profile = true; + } + Some(SlashCmd::Who) => { + self.composer.clear(); + self.who_open = true; + } + None => {} + }, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { self.handle_main_char(c); } @@ -421,10 +570,27 @@ impl App { _ => {} } } + Screen::Profile { editor } => match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Tab => editor.on_tab(), + KeyCode::BackTab => editor.on_backtab(), + KeyCode::Backspace => editor.on_backspace(), + KeyCode::Enter => profile_save = Some(editor.on_enter()), + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + editor.on_char(c); + } + _ => {} + }, } if let Some(kind) = wipe_confirm { return self.confirm_wipe(kind); } + if open_profile { + self.open_profile()?; + } + if let Some(draft) = profile_save { + self.save_profile(draft)?; + } Ok(None) } @@ -573,6 +739,43 @@ impl App { Ok(()) } + fn open_profile(&mut self) -> Result<(), String> { + let p = self.node.self_profile()?; + self.screen = Screen::Profile { + editor: ProfileEditor::new(&p.display_name, &p.bio, &p.xmr_addr), + }; + Ok(()) + } + + fn save_profile(&mut self, draft: ProfileDraft) -> Result<(), String> { + if let Err(e) = self + .node + .set_self_profile(&draft.display_name, &draft.bio, &draft.xmr_addr) + { + self.alert = Some(e); + return Ok(()); + } + self.screen = Screen::Main; + if let Some(friend) = self.friends.get(self.selected) { + let pk = friend.pubkey.clone(); + match self.rt.block_on(self.node.push_self_profile(&pk)) { + Ok(()) => self.status_note = Some("profile saved · sent".into()), + Err(e) => self.status_note = Some(format!("profile send failed: {e}")), + } + } else { + self.status_note = Some("profile saved".into()); + } + Ok(()) + } + + fn who_body(&self) -> String { + let Some(friend) = self.friends.get(self.selected) else { + return "no friend selected\n\nEsc closes".into(); + }; + let profile = self.node.friend_profile(&friend.pubkey).ok().flatten(); + who_overlay_text(&friend.fingerprint, &friend.onion, profile.as_ref()) + } + fn draw(&self, f: &mut Frame) { match &self.screen { Screen::Main => self.draw_main(f), @@ -581,9 +784,12 @@ impl App { Screen::Approve { payload } => draw_approve(f, payload), 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), } if self.help_open { - draw_help_overlay(f); + draw_text_overlay(f, "? help", help_overlay_text()); + } else if self.who_open { + draw_text_overlay(f, "/who", &self.who_body()); } } @@ -688,7 +894,7 @@ impl App { f.render_widget(chat, panes[1]); let cmd = if self.composer.is_empty() { - "/wipe /wipe-all".to_string() + "/wipe /wipe-all /profile /who".to_string() } else { self.composer.clone() }; @@ -803,7 +1009,7 @@ fn join_footer(parts: &[&str]) -> String { /// Main-screen key hints. Kept short so width 80 still shows TOR + identity + `? help`. pub fn main_footer_hints() -> &'static str { - "Tab F2 F3 F4 ? help Ctrl-Q" + "Tab F2 F3 F4 F5 ? help Ctrl-Q" } fn footer_hints(screen: &Screen) -> &'static str { @@ -817,6 +1023,7 @@ fn footer_hints(screen: &Screen) -> &'static str { WipeKind::Messages => "type WIPE Esc cancel ? help", WipeKind::All => "type WIPEALL Esc cancel ? help", }, + Screen::Profile { .. } => "Enter save Tab field Esc cancel ? help", } } @@ -898,9 +1105,27 @@ fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { ); } -fn draw_help_overlay(f: &mut Frame) { +fn draw_profile(f: &mut Frame, editor: &ProfileEditor) { + let labels = ["name", "bio", "xmr"]; + let values = [editor.display_name(), editor.bio(), editor.xmr_addr()]; + let mut body = String::from( + "Your profile (friend-visible)\nEnter saves Esc cancels Tab next field\n", + ); + for (i, (label, value)) in labels.iter().zip(values).enumerate() { + let mark = if editor.field() == i { ">" } else { " " }; + body.push_str(&format!("\n{mark} {label}: {value}")); + } + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(C_TEXT)) + .wrap(Wrap { trim: false }) + .block(themed_block("(o) profile")), + f.area(), + ); +} + +fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) { let area = f.area(); - let text = help_overlay_text(); let lines = text.lines().count() as u16 + 2; let text_w = text .lines() @@ -920,7 +1145,7 @@ fn draw_help_overlay(f: &mut Frame) { f.render_widget( Paragraph::new(text) .style(Style::default().fg(C_TEXT)) - .block(themed_block("? help")), + .block(themed_block(title)), popup, ); } diff --git a/tests/profile.rs b/tests/profile.rs new file mode 100644 index 0000000..efee510 --- /dev/null +++ b/tests/profile.rs @@ -0,0 +1,153 @@ +//! M7: signed friend-visible profile frames + slash parse. + +use ed25519_dalek::SigningKey; +use onionwire::Store; +use onionwire::profile::{self}; +use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash}; +use rand::rngs::OsRng; + +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_profile_upserts_friend_row_keeps_fingerprint() { + let (_dir, store) = store(); + let sk = SigningKey::generate(&mut OsRng); + let pk = sk.verifying_key().to_bytes(); + store + .upsert_friend(&pk, "alice.onion", Some("alice")) + .unwrap(); + let before = store.get_friend(&pk).unwrap().unwrap(); + + let prf = profile::sign(&sk.to_bytes(), "Ali", "hi", "", 2_000_000_000).expect("sign"); + assert!( + store.apply_profile(&pk, &prf).unwrap(), + "newer signed profile must apply" + ); + + let after = store.get_friend(&pk).unwrap().unwrap(); + assert_eq!(after.fingerprint, before.fingerprint); + assert_eq!(after.petname.as_deref(), Some("alice")); + assert_eq!(after.onion, "alice.onion"); + assert_eq!(store.friend_count().unwrap(), 1); + + let got = store.friend_profile(&pk).unwrap().expect("row"); + assert_eq!(got.display_name, "Ali"); + assert_eq!(got.bio, "hi"); + assert_eq!(got.xmr_addr, ""); + assert_eq!(got.updated_at, 2_000_000_000); +} + +#[test] +fn wrong_key_profile_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 first = profile::sign(&owner.to_bytes(), "real", "", "", 2_000_000_000).unwrap(); + assert!(store.apply_profile(&owner_pk, &first).unwrap()); + + let other = SigningKey::generate(&mut OsRng); + let spoof = profile::sign(&other.to_bytes(), "evil", "no", "", 2_000_000_001).unwrap(); + assert!(!store.apply_profile(&owner_pk, &spoof).unwrap()); + let got = store.friend_profile(&owner_pk).unwrap().unwrap(); + assert_eq!(got.display_name, "real"); +} + +#[test] +fn stale_ts_profile_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 newer = profile::sign(&sk.to_bytes(), "new", "", "", 100).unwrap(); + assert!(store.apply_profile(&pk, &newer).unwrap()); + let stale = profile::sign(&sk.to_bytes(), "old", "", "", 50).unwrap(); + assert!(!store.apply_profile(&pk, &stale).unwrap()); + assert_eq!( + store.friend_profile(&pk).unwrap().unwrap().display_name, + "new" + ); +} + +#[test] +fn unknown_pubkey_profile_is_ignored() { + let (_dir, store) = store(); + let sk = SigningKey::generate(&mut OsRng); + let pk = sk.verifying_key().to_bytes(); + let prf = profile::sign(&sk.to_bytes(), "ghost", "", "", 2_000_000_000).unwrap(); + assert!(!store.apply_profile(&pk, &prf).unwrap()); + assert!(store.friend_profile(&pk).unwrap().is_none()); + assert_eq!(store.friend_count().unwrap(), 0); +} + +#[test] +fn bio_over_512_rejected() { + let bio = "x".repeat(513); + let sk = [7u8; 32]; + assert!(profile::sign(&sk, "n", &bio, "", 1).is_err()); + let mut out = b"prf n\n".to_vec(); + out.extend_from_slice(bio.as_bytes()); + out.extend_from_slice(b"\n\n1\n"); + out.extend_from_slice("aa".repeat(64).as_bytes()); + assert!(profile::decode(&out).is_none()); +} + +#[test] +fn newline_in_name_rejected() { + let sk = [7u8; 32]; + assert!(profile::sign(&sk, "bad\nname", "bio", "", 1).is_err()); + assert!(profile::decode(b"prf bad\nname\nbio\n\n1\n00").is_none()); +} + +#[test] +fn chat_is_not_profile() { + assert!(profile::decode(b"hello wire").is_none()); +} + +#[test] +fn self_profile_roundtrip() { + let (_dir, store) = store(); + store.set_self_profile("me", "a bio", "4abc").unwrap(); + let got = store.self_profile().unwrap(); + assert_eq!(got.display_name, "me"); + assert_eq!(got.bio, "a bio"); + assert_eq!(got.xmr_addr, "4abc"); + assert!(got.updated_at > 0); + store.set_self_profile("", "", "").unwrap(); + let empty = store.self_profile().unwrap(); + assert_eq!(empty.display_name, ""); + assert!(empty.updated_at >= got.updated_at); +} + +#[test] +fn slash_profile_who_and_wipe_still_parse() { + assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile)); + assert_eq!(parse_cmd(" /profile "), Some(SlashCmd::Profile)); + assert_eq!(parse_cmd("/who"), Some(SlashCmd::Who)); + assert_eq!(parse_cmd("/who\n"), Some(SlashCmd::Who)); + assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages))); + assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages)); + assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All)); + assert_eq!(parse_slash("/profile"), None); + assert_eq!(parse_cmd("/rotate"), None); +} + +#[test] +fn profile_save_empty_name_ok_no_confirm_phrase() { + let saved = onionwire::tui::ProfileEditor::new("", "", "").on_enter(); + assert_eq!(saved.display_name, ""); + assert_eq!(saved.bio, ""); + assert_eq!(saved.xmr_addr, ""); +} + +#[test] +fn who_empty_says_no_profile_yet() { + let t = onionwire::tui::who_overlay_text("abcd", "x.onion", None); + assert!(t.contains("no profile yet")); + assert!(t.contains("abcd")); + assert!(t.contains("x.onion")); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 913833d..9ef12bb 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -1,8 +1,8 @@ //! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text. use onionwire::tui::{ - banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph, - status_footer, wordmark_banner, Pane, + Pane, banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph, + status_footer, wordmark_banner, }; #[test] @@ -38,7 +38,7 @@ fn onion_glyph_is_nonempty() { fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); - for needle in ["Tab", "F2", "F3", "F4", "Ctrl-Q", "?"] { + for needle in ["Tab", "F2", "F3", "F4", "F5", "/profile", "Ctrl-Q", "?"] { assert!(help.contains(needle), "help overlay missing {needle:?}"); } for line in help.lines() { @@ -61,10 +61,10 @@ fn banner_for_width_collapses_when_narrow() { #[test] fn chrome_renders_at_80x24_and_120x40() { + use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::layout::{Constraint, Layout}; use ratatui::widgets::Paragraph; - use ratatui::Terminal; let fp = "abcdef0123456789"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";