[verified] feat(tui): drop unicode QR, share onionwire:v1 invite
Some checks failed
ci / test (push) Has been cancelled
Some checks failed
ci / test (push) Has been cancelled
F2 shows the invite string with fp/onion hints instead of a terminal QR. Keep encode/decode and F3 paste; drop the qrcode crate.
This commit is contained in:
parent
e902a9041d
commit
4fb47444d5
9 changed files with 64 additions and 55 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -2615,7 +2615,6 @@ dependencies = [
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures",
|
"futures",
|
||||||
"qrcode",
|
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
@ -3070,12 +3069,6 @@ dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "qrcode"
|
|
||||||
version = "0.14.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "ti
|
||||||
tor-cell = "0.46"
|
tor-cell = "0.46"
|
||||||
tor-hsservice = "0.46"
|
tor-hsservice = "0.46"
|
||||||
tor-rtcompat = { version = "0.46", features = ["tokio"] }
|
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"] }
|
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
snow = "0.10"
|
snow = "0.10"
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# OnionWire
|
# OnionWire
|
||||||
|
|
||||||
Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti).
|
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.
|
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
|
## 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)
|
## 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 |
|
| `j` `k` / `↑` `↓` | Move or scroll the focused pane |
|
||||||
| `g` / `G` | Jump to top / bottom of the focused pane |
|
| `g` / `G` | Jump to top / bottom of the focused pane |
|
||||||
| `?` | Keybinding help (`Esc` closes) |
|
| `?` | Keybinding help (`Esc` closes) |
|
||||||
| `F2` | Share: terminal QR + payload |
|
| `F2` | Share: invite string (`onionwire:v1:…`) |
|
||||||
| `F3` | Paste a friend’s payload |
|
| `F3` | Paste a friend’s payload |
|
||||||
| `F4` | Rotate **onion** (locator only) |
|
| `F4` | Rotate **onion** (locator only) |
|
||||||
| `F5` | Selected friend’s profile (`/who`) |
|
| `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 |
|
| `Esc` | Close overlay / back to Main / clear composer |
|
||||||
| `Ctrl-Q` | Quit |
|
| `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=…`
|
`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
|
## 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.
|
- Your identity fingerprint stays the same.
|
||||||
- A new onion is published; the old one is hard-cut (no dual-host grace).
|
- A new onion is published; the old one is hard-cut (no dual-host grace).
|
||||||
- Online friends get a signed `loc` frame.
|
- 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
|
## 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. |
|
| `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. |
|
| Blank / broken TUI | Run in a real terminal emulator, not `nohup` / systemd without a TTY. |
|
||||||
| Two chats, same laptop | Separate `ONIONWIRE_HOME` per process. |
|
| 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
|
## Not in v1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
## 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
|
## v1 stores plaintext locally
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
use qrcode::render::unicode::Dense1x2;
|
|
||||||
use qrcode::QrCode;
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
|
@ -66,11 +64,6 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_unicode(payload: &str) -> Result<String> {
|
|
||||||
let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?;
|
|
||||||
Ok(code.render::<Dense1x2>().build())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
|
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
|
||||||
let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
|
let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
|
||||||
msg.extend_from_slice(k.as_bytes());
|
msg.extend_from_slice(k.as_bytes());
|
||||||
|
|
|
||||||
29
src/tui.rs
29
src/tui.rs
|
|
@ -27,7 +27,7 @@ pub fn rotate_screen_text() -> &'static str {
|
||||||
"Rotate onion address?\n\
|
"Rotate onion address?\n\
|
||||||
Your identity key stays the same.\n\
|
Your identity key stays the same.\n\
|
||||||
Online friends get a signed location update.\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"
|
Type ROTATE to confirm Esc to cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,8 +61,8 @@ OnionWire keys\n\
|
||||||
g / G jump to top / bottom\n\
|
g / G jump to top / bottom\n\
|
||||||
Enter /wipe /wipe-all /profile /who /pay /tip\n\
|
Enter /wipe /wipe-all /profile /who /pay /tip\n\
|
||||||
Esc close overlay, go back, clear composer\n\
|
Esc close overlay, go back, clear composer\n\
|
||||||
F2 share QR\n\
|
F2 share invite\n\
|
||||||
F3 paste a friend QR\n\
|
F3 paste invite\n\
|
||||||
F4 rotate onion locator\n\
|
F4 rotate onion locator\n\
|
||||||
F5 selected friend's profile (/who)\n\
|
F5 selected friend's profile (/who)\n\
|
||||||
/profile edit name, bio, Monero address\n\
|
/profile edit name, bio, Monero address\n\
|
||||||
|
|
@ -528,7 +528,6 @@ struct App {
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Main,
|
Main,
|
||||||
Share {
|
Share {
|
||||||
art: String,
|
|
||||||
payload: String,
|
payload: String,
|
||||||
},
|
},
|
||||||
Paste {
|
Paste {
|
||||||
|
|
@ -981,8 +980,7 @@ impl App {
|
||||||
|
|
||||||
fn open_share(&mut self) -> Result<(), String> {
|
fn open_share(&mut self) -> Result<(), String> {
|
||||||
let payload = self.node.qr_payload()?;
|
let payload = self.node.qr_payload()?;
|
||||||
let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?;
|
self.screen = Screen::Share { payload };
|
||||||
self.screen = Screen::Share { art, payload };
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1090,7 +1088,7 @@ impl App {
|
||||||
fn draw(&self, f: &mut Frame) {
|
fn draw(&self, f: &mut Frame) {
|
||||||
match &self.screen {
|
match &self.screen {
|
||||||
Screen::Main => self.draw_main(f),
|
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::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
|
||||||
Screen::Approve { payload } => draw_approve(f, payload),
|
Screen::Approve { payload } => draw_approve(f, payload),
|
||||||
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
||||||
|
|
@ -1244,7 +1242,7 @@ impl App {
|
||||||
|
|
||||||
fn chat_lines(&self) -> Vec<String> {
|
fn chat_lines(&self) -> Vec<String> {
|
||||||
let Some(friend) = self.friends.get(self.selected) else {
|
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) {
|
match self.node.list_messages(&friend.pubkey) {
|
||||||
Ok(msgs) if msgs.is_empty() => vec!["no messages".into()],
|
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))
|
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_share(f: &mut Frame, art: &str, payload: &str) {
|
pub fn draw_share(f: &mut Frame, payload: &str) {
|
||||||
let glyph = onion_glyph();
|
let (fp, onion) = match qr::decode(payload) {
|
||||||
let body = format!("{glyph}\n\n{art}\n{payload}\n\nEsc back");
|
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(
|
f.render_widget(
|
||||||
Paragraph::new(body)
|
Paragraph::new(body)
|
||||||
.style(Style::default().fg(C_TEXT))
|
.style(Style::default().fg(C_TEXT))
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.block(themed_block("(o) share")),
|
.block(themed_block("(o) invite")),
|
||||||
f.area(),
|
f.area(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1379,7 +1382,7 @@ fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) {
|
||||||
Paragraph::new(body)
|
Paragraph::new(body)
|
||||||
.style(style)
|
.style(style)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.block(themed_block("(o) paste QR")),
|
.block(themed_block("(o) paste invite")),
|
||||||
f.area(),
|
f.area(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
tests/qr.rs
13
tests/qr.rs
|
|
@ -90,19 +90,6 @@ fn bad_sig_is_err() {
|
||||||
assert!(qr::decode(&format!("{head}:sig={flipped}")).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]
|
#[test]
|
||||||
fn same_k_rescan_updates_onion_not_duplicate() {
|
fn same_k_rescan_updates_onion_not_duplicate() {
|
||||||
let _home = TempHome::new();
|
let _home = TempHome::new();
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ fn rotate_prompt_text_matches_spec() {
|
||||||
assert!(t.contains("Rotate onion address?"));
|
assert!(t.contains("Rotate onion address?"));
|
||||||
assert!(t.contains("Your identity key stays the same."));
|
assert!(t.contains("Your identity key stays the same."));
|
||||||
assert!(t.contains("Online friends get a signed location update."));
|
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("Type ROTATE to confirm"));
|
||||||
assert!(t.contains("Esc to cancel"));
|
assert!(t.contains("Esc to cancel"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
|
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
|
||||||
|
|
||||||
|
use onionwire::qr;
|
||||||
use onionwire::tui::{
|
use onionwire::tui::{
|
||||||
Pane, banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
|
banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
|
||||||
status_footer, wordmark_banner,
|
onion_glyph, status_footer, wordmark_banner, Pane,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -64,10 +65,10 @@ fn banner_for_width_collapses_when_narrow() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chrome_renders_at_80x24_and_120x40() {
|
fn chrome_renders_at_80x24_and_120x40() {
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
let fp = "abcdef0123456789";
|
let fp = "abcdef0123456789";
|
||||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||||
|
|
@ -162,3 +163,36 @@ fn pane_focus_cycles_roster_chat_composer() {
|
||||||
fn restore_terminal_is_callable_without_panic() {
|
fn restore_terminal_is_callable_without_panic() {
|
||||||
onionwire::tui::restore_terminal();
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue