From 4fb47444d50043cba4b6459fc98ccbe4ab179a1a Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 16:53:03 -0400 Subject: [PATCH] [verified] feat(tui): drop unicode QR, share onionwire:v1 invite F2 shows the invite string with fp/onion hints instead of a terminal QR. Keep encode/decode and F3 paste; drop the qrcode crate. --- Cargo.lock | 7 ------- Cargo.toml | 1 - README.md | 16 ++++++++-------- docs/THREAT_MODEL.md | 4 ++-- src/qr.rs | 7 ------- src/tui.rs | 29 ++++++++++++++++------------- tests/qr.rs | 13 ------------- tests/rotate.rs | 2 +- tests/tui_chrome.rs | 40 +++++++++++++++++++++++++++++++++++++--- 9 files changed, 64 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f57393d..05add4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2615,7 +2615,6 @@ dependencies = [ "chacha20poly1305", "ed25519-dalek", "futures", - "qrcode", "rand 0.8.8", "ratatui", "rusqlite", @@ -3070,12 +3069,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" - [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index d125aee..8091bb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "ti tor-cell = "0.46" tor-hsservice = "0.46" tor-rtcompat = { version = "0.46", features = ["tokio"] } -qrcode = { version = "0.14.1", default-features = false } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } x25519-dalek = { version = "2", features = ["static_secrets"] } snow = "0.10" diff --git a/README.md b/README.md index 194b5d7..b045f83 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OnionWire Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti). -Two people scan a QR, then chat. No hosted server. Identity is a public key; the onion is only a locator. +Two people share an invite string, then chat. No hosted server. Identity is a public key; the onion is only a locator. You do **not** install a `tor` daemon, `torrc`, Prosody, or XMPP. OnionWire embeds Arti and publishes its own v3 onion. @@ -110,7 +110,7 @@ On start you should see `onionwire: bootstrapping Arti…` on stderr. Directory ## Friends are keys -A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is only where they are reachable right now. Re-scanning the same `k` updates the locator; it never creates a second person. +A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is only where they are reachable right now. Pasting the same `k` updates the locator; it never creates a second person. ## Keys (TUI) @@ -123,7 +123,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j | `j` `k` / `↑` `↓` | Move or scroll the focused pane | | `g` / `G` | Jump to top / bottom of the focused pane | | `?` | Keybinding help (`Esc` closes) | -| `F2` | Share: terminal QR + payload | +| `F2` | Share: invite string (`onionwire:v1:…`) | | `F3` | Paste a friend’s payload | | `F4` | Rotate **onion** (locator only) | | `F5` | Selected friend’s profile (`/who`) | @@ -131,13 +131,13 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j | `Esc` | Close overlay / back to Main / clear composer | | `Ctrl-Q` | Quit | -## F2 QR +## F2 invite -`F2` shows a terminal QR and the payload: +`F2` shows the invite string (mouse-select to copy): `onionwire:v1:k=…:o=…:spk=…:sig=…` -`F3` pastes a payload. Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only. +Give that string to a friend. `F3` pastes it. Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only. ## F4 rotate onion @@ -146,7 +146,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j - Your identity fingerprint stays the same. - A new onion is published; the old one is hard-cut (no dual-host grace). - Online friends get a signed `loc` frame. -- Offline friends cannot find you until they rescan the new QR. There is no directory. +- Offline friends cannot find you until they F3-paste the new invite. There is no directory. ## Fail closed @@ -259,7 +259,7 @@ Do not run `cargo publish`; `publish = false`. CI needs the repo secret | `onionwire: unknown argument` | No subcommands. Flags are `--version` / `--help` only, then the TUI. | | Blank / broken TUI | Run in a real terminal emulator, not `nohup` / systemd without a TTY. | | Two chats, same laptop | Separate `ONIONWIRE_HOME` per process. | -| Friend cannot find you after F4 | Expected if they were offline. They must F3-paste the new QR. Same `k` updates the row. | +| Friend cannot find you after F4 | Expected if they were offline. They must F3-paste the new invite. Same `k` updates the row. | ## Not in v1 diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 5e01467..43d739f 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -8,11 +8,11 @@ Each install hosts its own v3 onion and dials friends’ onions through in-proce ## Location updates are not a missing-person finder -A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only if** that friend is already in the roster, the signature verifies against the pinned pubkey, and `ts` is newer. There is no directory, DHT, or introduction server. If you rotated while they were offline, they cannot find you until they rescan your QR. OnionWire will not hunt for a missing person. +A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only if** that friend is already in the roster, the signature verifies against the pinned pubkey, and `ts` is newer. There is no directory, DHT, or introduction server. If you rotated while they were offline, they cannot find you until they F3-paste your new invite. OnionWire will not hunt for a missing person. ## Rotating the onion does not revoke a friend -`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 QR/rescan 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. +`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 diff --git a/src/qr.rs b/src/qr.rs index b6f5d93..59c5e74 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -1,6 +1,4 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use qrcode::render::unicode::Dense1x2; -use qrcode::QrCode; pub type Result = std::result::Result; @@ -66,11 +64,6 @@ pub fn decode(raw: &str) -> Result { }) } -pub fn render_unicode(payload: &str) -> Result { - let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?; - Ok(code.render::().build()) -} - fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec { let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len()); msg.extend_from_slice(k.as_bytes()); diff --git a/src/tui.rs b/src/tui.rs index 2460c34..61a3625 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -27,7 +27,7 @@ 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\ +Offline friends CANNOT find you until they F3-paste your new invite.\n\ Type ROTATE to confirm Esc to cancel" } @@ -61,8 +61,8 @@ OnionWire keys\n\ g / G jump to top / bottom\n\ Enter /wipe /wipe-all /profile /who /pay /tip\n\ Esc close overlay, go back, clear composer\n\ - F2 share QR\n\ - F3 paste a friend QR\n\ + F2 share invite\n\ + F3 paste invite\n\ F4 rotate onion locator\n\ F5 selected friend's profile (/who)\n\ /profile edit name, bio, Monero address\n\ @@ -528,7 +528,6 @@ struct App { enum Screen { Main, Share { - art: String, payload: String, }, Paste { @@ -981,8 +980,7 @@ impl App { fn open_share(&mut self) -> Result<(), 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 }; + self.screen = Screen::Share { payload }; Ok(()) } @@ -1090,7 +1088,7 @@ impl App { fn draw(&self, f: &mut Frame) { match &self.screen { Screen::Main => self.draw_main(f), - Screen::Share { art, payload } => draw_share(f, art, payload), + Screen::Share { payload } => draw_share(f, payload), Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()), Screen::Approve { payload } => draw_approve(f, payload), Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), @@ -1244,7 +1242,7 @@ impl App { fn chat_lines(&self) -> Vec { let Some(friend) = self.friends.get(self.selected) else { - return vec!["no friends yet".into(), "F3 paste a QR 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()], @@ -1355,14 +1353,19 @@ fn themed_block(title: &str) -> Block<'_> { .title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD)) } -fn draw_share(f: &mut Frame, art: &str, payload: &str) { - let glyph = onion_glyph(); - let body = format!("{glyph}\n\n{art}\n{payload}\n\nEsc back"); +pub fn draw_share(f: &mut Frame, payload: &str) { + let (fp, onion) = match qr::decode(payload) { + Ok(p) => (truncate_fp(&to_hex(&p.pubkey)), p.onion), + Err(_) => ("?".into(), "?".into()), + }; + let body = format!( + "give this string to a friend; they F3 paste it\n\nfp {fp}\nonion {onion}\n\n{payload}" + ); f.render_widget( Paragraph::new(body) .style(Style::default().fg(C_TEXT)) .wrap(Wrap { trim: false }) - .block(themed_block("(o) share")), + .block(themed_block("(o) invite")), f.area(), ); } @@ -1379,7 +1382,7 @@ fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) { Paragraph::new(body) .style(style) .wrap(Wrap { trim: false }) - .block(themed_block("(o) paste QR")), + .block(themed_block("(o) paste invite")), f.area(), ); } diff --git a/tests/qr.rs b/tests/qr.rs index 351390c..ec856ff 100644 --- a/tests/qr.rs +++ b/tests/qr.rs @@ -90,19 +90,6 @@ fn bad_sig_is_err() { assert!(qr::decode(&format!("{head}:sig={flipped}")).is_err()); } -#[test] -fn unicode_qr_renders() { - let _home = TempHome::new(); - let store = Store::open().expect("open"); - let me = store.self_identity().expect("self"); - let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).expect("encode"); - let art = qr::render_unicode(&raw).expect("render"); - assert!( - art.contains('█') || art.contains('▀') || art.contains('▄') || art.contains('▌'), - "expected unicode blocks, got {art:?}" - ); -} - #[test] fn same_k_rescan_updates_onion_not_duplicate() { let _home = TempHome::new(); diff --git a/tests/rotate.rs b/tests/rotate.rs index d009a26..0e09f23 100644 --- a/tests/rotate.rs +++ b/tests/rotate.rs @@ -126,7 +126,7 @@ fn rotate_prompt_text_matches_spec() { 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("Offline friends CANNOT find you until they F3-paste your new invite.")); assert!(t.contains("Type ROTATE to confirm")); assert!(t.contains("Esc to cancel")); } diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 8df424b..2800d07 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -1,8 +1,9 @@ //! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text. +use onionwire::qr; use onionwire::tui::{ - Pane, banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph, - status_footer, wordmark_banner, + banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, + onion_glyph, status_footer, wordmark_banner, Pane, }; #[test] @@ -64,10 +65,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"; @@ -162,3 +163,36 @@ fn pane_focus_cycles_roster_chat_composer() { fn restore_terminal_is_callable_without_panic() { onionwire::tui::restore_terminal(); } + +#[test] +fn share_screen_shows_invite_not_qr_at_80x24() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + let sk = [7u8; 32]; + let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; + let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode"); + assert!(payload.starts_with("onionwire:v1:")); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|f| draw_share(f, &payload)).expect("draw"); + let buf = terminal.backend().buffer(); + let mut screen = String::new(); + for y in 0..24u16 { + for x in 0..80u16 { + screen.push_str(buf[(x, y)].symbol()); + } + screen.push('\n'); + } + assert!( + screen.contains("onionwire:v1:"), + "share screen missing invite prefix:\n{screen}" + ); + for ch in ['█', '▀', '▄'] { + assert!( + !screen.contains(ch), + "share screen still has QR block {ch:?}:\n{screen}" + ); + } +}