Add /wipe, /wipe-all, and threat model docs.
/wipe overwrites the message log and VACUUMs; identity and friends stay. /wipe-all deletes the data dir after confirm so the next open is a new person. Neither runs without a typed confirm screen.
This commit is contained in:
parent
07aa97711c
commit
5412e020bb
7 changed files with 415 additions and 18 deletions
63
README.md
63
README.md
|
|
@ -1,14 +1,63 @@
|
||||||
# OnionWire
|
# OnionWire
|
||||||
|
|
||||||
Lean Tor messenger: one Rust process (ratatui + E2EE + 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.
|
Two people scan a QR, then chat. No hosted server. Identity is a public key; the onion is only a locator.
|
||||||
|
|
||||||
## Locked model
|
## First run
|
||||||
|
|
||||||
- **Identity = ed25519 public key.** Friends are `UNIQUE(pubkey)`.
|
```
|
||||||
- **Onion = locator only.** It can change. Never treat `.onion` as the roster key.
|
cargo run --release
|
||||||
- **F4 rotates the onion** (confirm by typing `ROTATE`). Same identity key. Offline friends need a QR rescan.
|
```
|
||||||
- **No XMPP, no Prosody, no s2s, no C-tor binary, no DHT, no outbox in v1.**
|
|
||||||
|
Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
||||||
|
|
||||||
|
Arti bootstraps Tor and publishes a v3 onion service. Onion services in Arti are still **experimental**. OnionWire fails closed if the HS cannot come up — it does not fall back to a `tor` binary or C-tor.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## F2 QR
|
||||||
|
|
||||||
|
`F2` shows a terminal QR and the payload:
|
||||||
|
|
||||||
|
`onionwire:v1:k=…:o=…:spk=…:sig=…`
|
||||||
|
|
||||||
|
`F3` pastes a payload. Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only.
|
||||||
|
|
||||||
|
## F4 rotate onion
|
||||||
|
|
||||||
|
`F4` is a locator change, not a new identity. Confirm by typing `ROTATE` (Enter alone does nothing).
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Fail closed
|
||||||
|
|
||||||
|
If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server.
|
||||||
|
|
||||||
|
## Wipe
|
||||||
|
|
||||||
|
Composer (bottom of the roster screen):
|
||||||
|
|
||||||
|
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
|
||||||
|
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Esc cancels. Nothing is wiped without confirm.
|
||||||
|
|
||||||
|
## Seized laptop
|
||||||
|
|
||||||
|
v1 stores **plaintext** on disk:
|
||||||
|
|
||||||
|
- message log (sqlite `messages.plaintext`)
|
||||||
|
- your identity secret key (`self.identity_sk`)
|
||||||
|
- friend public keys and current locators
|
||||||
|
|
||||||
|
Full-disk encryption plus `/wipe` / `/wipe-all` is the mitigation. There is no sqlcipher in v1.
|
||||||
|
|
||||||
|
## Not in v1
|
||||||
|
|
||||||
|
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher.
|
||||||
|
|
||||||
Plan of record: `/home/lancelot/.hermes/plans/2026-09-10_004409-onionwire-lean.md`
|
Plan of record: `/home/lancelot/.hermes/plans/2026-09-10_004409-onionwire-lean.md`
|
||||||
|
|
||||||
|
|
|
||||||
27
docs/THREAT_MODEL.md
Normal file
27
docs/THREAT_MODEL.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# OnionWire threat model (v1)
|
||||||
|
|
||||||
|
OnionWire is a two-party chat over Tor onion services. There is no chat server.
|
||||||
|
|
||||||
|
## Tor relays are not a chat server
|
||||||
|
|
||||||
|
Each install hosts its own v3 onion and dials friends’ onions through in-process Arti. Relays move cells; they never see plaintext, never store messages, and never hold a roster. Compromising a relay is not compromising OnionWire’s message store. The store is the sqlite file on the endpoint.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## v1 stores plaintext locally
|
||||||
|
|
||||||
|
The message log, identity secret key, and friend public keys sit on disk unencrypted (aside from whatever the OS/FDE provides). A seized laptop yields the chat history and who you talk to. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. sqlcipher is out of v1.
|
||||||
|
|
||||||
|
## Fail closed
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Out of v1
|
||||||
|
|
||||||
|
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher.
|
||||||
13
src/main.rs
13
src/main.rs
|
|
@ -1,3 +1,5 @@
|
||||||
|
use onionwire::tui::AppExit;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
if let Err(e) = boot().await {
|
if let Err(e) = boot().await {
|
||||||
|
|
@ -9,9 +11,14 @@ async fn main() {
|
||||||
async fn boot() -> Result<(), String> {
|
async fn boot() -> Result<(), String> {
|
||||||
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
|
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
|
||||||
eprintln!("onionwire: bootstrapping Arti…");
|
eprintln!("onionwire: bootstrapping Arti…");
|
||||||
let node = onionwire::node::Node::start(home).await?;
|
let node = onionwire::node::Node::start(home.clone()).await?;
|
||||||
let handle = tokio::runtime::Handle::current();
|
let handle = tokio::runtime::Handle::current();
|
||||||
tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle))
|
let exit = tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())??;
|
||||||
|
if exit == AppExit::WipeAll {
|
||||||
|
onionwire::Store::wipe_all(&home).map_err(|e| e.to_string())?;
|
||||||
|
eprintln!("onionwire: all local data wiped");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,14 @@ impl Node {
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn wipe_messages(&self) -> Result<(), String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.wipe_messages()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
Duration::from_secs(20),
|
Duration::from_secs(20),
|
||||||
|
|
|
||||||
19
src/store.rs
19
src/store.rs
|
|
@ -364,6 +364,25 @@ impl Store {
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay.
|
||||||
|
pub fn wipe_messages(&self) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
self.conn.execute("DELETE FROM messages", [])?;
|
||||||
|
self.conn.execute_batch("VACUUM")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the data dir. Next `open_at` is a new identity. Caller must drop Store first.
|
||||||
|
pub fn wipe_all(home: &Path) -> Result<()> {
|
||||||
|
if home.exists() {
|
||||||
|
fs::remove_dir_all(home)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
||||||
|
|
|
||||||
186
src/tui.rs
186
src/tui.rs
|
|
@ -23,6 +23,97 @@ Offline friends CANNOT find you until they rescan your QR.\n\
|
||||||
Type ROTATE to confirm Esc to cancel"
|
Type ROTATE to confirm Esc to cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WipeKind {
|
||||||
|
Messages,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WipeDecision {
|
||||||
|
Pending,
|
||||||
|
Confirm,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AppExit {
|
||||||
|
Quit,
|
||||||
|
WipeAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||||
|
match raw.trim() {
|
||||||
|
"/wipe" => Some(WipeKind::Messages),
|
||||||
|
"/wipe-all" => Some(WipeKind::All),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
WipeKind::Messages => {
|
||||||
|
"Wipe message log?\n\
|
||||||
|
Identity key and friends stay.\n\
|
||||||
|
Type WIPE to confirm Esc to cancel"
|
||||||
|
}
|
||||||
|
WipeKind::All => {
|
||||||
|
"Wipe ALL local data?\n\
|
||||||
|
This deletes your identity key. You become a new person.\n\
|
||||||
|
Type WIPEALL to confirm Esc to cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WipePrompt {
|
||||||
|
expected: &'static str,
|
||||||
|
buf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WipePrompt {
|
||||||
|
pub fn messages() -> Self {
|
||||||
|
Self {
|
||||||
|
expected: "WIPE",
|
||||||
|
buf: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> Self {
|
||||||
|
Self {
|
||||||
|
expected: "WIPEALL",
|
||||||
|
buf: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_esc(&self) -> WipeDecision {
|
||||||
|
WipeDecision::Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_char(&mut self, c: char) -> WipeDecision {
|
||||||
|
if c == '\n' {
|
||||||
|
return WipeDecision::Pending;
|
||||||
|
}
|
||||||
|
if c == '\u{8}' {
|
||||||
|
self.buf.pop();
|
||||||
|
return WipeDecision::Pending;
|
||||||
|
}
|
||||||
|
if !c.is_ascii_alphabetic() {
|
||||||
|
return WipeDecision::Pending;
|
||||||
|
}
|
||||||
|
self.buf.push(c);
|
||||||
|
if self.buf == self.expected {
|
||||||
|
WipeDecision::Confirm
|
||||||
|
} else {
|
||||||
|
WipeDecision::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn typed(&self) -> &str {
|
||||||
|
&self.buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RotateDecision {
|
pub enum RotateDecision {
|
||||||
Pending,
|
Pending,
|
||||||
|
|
@ -68,7 +159,7 @@ impl RotatePrompt {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<(), String> {
|
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
|
||||||
let mut app = App::new(node, rt)?;
|
let mut app = App::new(node, rt)?;
|
||||||
let mut terminal = ratatui::init();
|
let mut terminal = ratatui::init();
|
||||||
let result = app.run(&mut terminal);
|
let result = app.run(&mut terminal);
|
||||||
|
|
@ -86,6 +177,7 @@ struct App {
|
||||||
me_onion: String,
|
me_onion: String,
|
||||||
alert: Option<String>,
|
alert: Option<String>,
|
||||||
status_note: Option<String>,
|
status_note: Option<String>,
|
||||||
|
composer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Screen {
|
enum Screen {
|
||||||
|
|
@ -94,6 +186,7 @@ enum Screen {
|
||||||
Paste { buf: String, err: Option<String> },
|
Paste { buf: String, err: Option<String> },
|
||||||
Approve { payload: QrPayload },
|
Approve { payload: QrPayload },
|
||||||
Rotate { prompt: RotatePrompt },
|
Rotate { prompt: RotatePrompt },
|
||||||
|
Wipe { kind: WipeKind, prompt: WipePrompt },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -109,10 +202,11 @@ impl App {
|
||||||
selected: 0,
|
selected: 0,
|
||||||
alert: None,
|
alert: None,
|
||||||
status_note: None,
|
status_note: None,
|
||||||
|
composer: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<(), String> {
|
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<AppExit, String> {
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?;
|
terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?;
|
||||||
let Event::Key(key) = event::read().map_err(io_err)? else {
|
let Event::Key(key) = event::read().map_err(io_err)? else {
|
||||||
|
|
@ -122,13 +216,16 @@ impl App {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
return Ok(());
|
return Ok(AppExit::Quit);
|
||||||
|
}
|
||||||
|
if let Some(exit) = self.handle_key(key)? {
|
||||||
|
return Ok(exit);
|
||||||
}
|
}
|
||||||
self.handle_key(key)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> Result<(), String> {
|
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<AppExit>, String> {
|
||||||
|
let mut wipe_confirm = None;
|
||||||
match &mut self.screen {
|
match &mut self.screen {
|
||||||
Screen::Main => match key.code {
|
Screen::Main => match key.code {
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
|
|
@ -151,6 +248,23 @@ impl App {
|
||||||
prompt: RotatePrompt::new(),
|
prompt: RotatePrompt::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
KeyCode::Esc => self.composer.clear(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.composer.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(kind) = parse_slash(&self.composer) {
|
||||||
|
self.composer.clear();
|
||||||
|
let prompt = match kind {
|
||||||
|
WipeKind::Messages => WipePrompt::messages(),
|
||||||
|
WipeKind::All => WipePrompt::all(),
|
||||||
|
};
|
||||||
|
self.screen = Screen::Wipe { kind, prompt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.composer.push(c);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Screen::Share { .. } => {
|
Screen::Share { .. } => {
|
||||||
|
|
@ -194,8 +308,38 @@ impl App {
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Screen::Wipe { kind, 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) == WipeDecision::Confirm {
|
||||||
|
wipe_confirm = Some(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
prompt.on_char('\u{8}');
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(kind) = wipe_confirm {
|
||||||
|
return self.confirm_wipe(kind);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_wipe(&mut self, kind: WipeKind) -> Result<Option<AppExit>, String> {
|
||||||
|
match kind {
|
||||||
|
WipeKind::Messages => {
|
||||||
|
self.node.wipe_messages()?;
|
||||||
|
self.status_note = Some("wiped messages".into());
|
||||||
|
self.screen = Screen::Main;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
WipeKind::All => Ok(Some(AppExit::WipeAll)),
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_rotate(&mut self) -> Result<(), String> {
|
fn confirm_rotate(&mut self) -> Result<(), String> {
|
||||||
|
|
@ -268,12 +412,18 @@ impl App {
|
||||||
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()),
|
||||||
|
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_main(&self, f: &mut Frame) {
|
fn draw_main(&self, f: &mut Frame) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let cols = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
|
let cols = Layout::vertical([
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
let body = if self.alert.is_some() {
|
let body = if self.alert.is_some() {
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0])
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0])
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -329,7 +479,13 @@ impl App {
|
||||||
truncate_fp(&self.me_fp),
|
truncate_fp(&self.me_fp),
|
||||||
onion
|
onion
|
||||||
);
|
);
|
||||||
f.render_widget(Paragraph::new(status), cols[1]);
|
let cmd = if self.composer.is_empty() {
|
||||||
|
"/wipe /wipe-all".to_string()
|
||||||
|
} else {
|
||||||
|
self.composer.clone()
|
||||||
|
};
|
||||||
|
f.render_widget(Paragraph::new(cmd), cols[1]);
|
||||||
|
f.render_widget(Paragraph::new(status), cols[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,6 +533,20 @@ fn draw_rotate(f: &mut Frame, typed: &str) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
|
||||||
|
let title = match kind {
|
||||||
|
WipeKind::Messages => "/wipe",
|
||||||
|
WipeKind::All => "/wipe-all",
|
||||||
|
};
|
||||||
|
let body = format!("{}\n\n{typed}", wipe_screen_text(kind));
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title)),
|
||||||
|
f.area(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn truncate_fp(fp: &str) -> String {
|
fn truncate_fp(fp: &str) -> String {
|
||||||
let s: String = fp.chars().take(8).collect();
|
let s: String = fp.chars().take(8).collect();
|
||||||
format!("{s}…")
|
format!("{s}…")
|
||||||
|
|
|
||||||
117
tests/wipe.rs
Normal file
117
tests/wipe.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
|
||||||
|
|
||||||
|
use onionwire::Store;
|
||||||
|
use onionwire::tui::{WipeDecision, WipeKind, WipePrompt, parse_slash, wipe_screen_text};
|
||||||
|
|
||||||
|
fn pk(tag: u8) -> [u8; 32] {
|
||||||
|
let mut k = [0u8; 32];
|
||||||
|
k[0] = tag;
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_contains(home: &std::path::Path, needle: &[u8]) -> bool {
|
||||||
|
let Ok(bytes) = std::fs::read(home.join("onionwire.db")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
bytes.windows(needle.len()).any(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_clears_messages_keeps_self_and_friends() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = Store::open_at(dir.path()).expect("open");
|
||||||
|
let me = store.self_identity().unwrap();
|
||||||
|
store
|
||||||
|
.upsert_friend(&pk(1), "a.onion", Some("alice"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.append_message(&pk(1), "out", b"secret-log-line-xyz")
|
||||||
|
.unwrap();
|
||||||
|
store.append_message(&pk(1), "in", b"reply").unwrap();
|
||||||
|
assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 2);
|
||||||
|
|
||||||
|
store.wipe_messages().expect("wipe");
|
||||||
|
|
||||||
|
assert!(store.list_messages(&pk(1)).unwrap().is_empty());
|
||||||
|
assert_eq!(store.friend_count().unwrap(), 1);
|
||||||
|
let f = store.get_friend(&pk(1)).unwrap().expect("friend");
|
||||||
|
assert_eq!(f.petname.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(f.onion, "a.onion");
|
||||||
|
let me2 = store.self_identity().unwrap();
|
||||||
|
assert_eq!(me.identity_pk, me2.identity_pk);
|
||||||
|
assert_eq!(me.identity_sk, me2.identity_sk);
|
||||||
|
drop(store);
|
||||||
|
assert!(
|
||||||
|
!db_contains(dir.path(), b"secret-log-line-xyz"),
|
||||||
|
"wipe must overwrite plaintext before VACUUM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let home = dir.path().join("ow");
|
||||||
|
let store = Store::open_at(&home).expect("open");
|
||||||
|
let old_pk = store.self_identity().unwrap().identity_pk;
|
||||||
|
store.upsert_friend(&pk(1), "a.onion", None).unwrap();
|
||||||
|
store.append_message(&pk(1), "out", b"gone").unwrap();
|
||||||
|
drop(store);
|
||||||
|
assert!(home.join("onionwire.db").is_file());
|
||||||
|
|
||||||
|
Store::wipe_all(&home).expect("wipe-all");
|
||||||
|
assert!(!home.exists(), "data dir gone");
|
||||||
|
|
||||||
|
let store2 = Store::open_at(&home).expect("reopen");
|
||||||
|
let new_pk = store2.self_identity().unwrap().identity_pk;
|
||||||
|
assert_ne!(old_pk, new_pk, "new identity key = new person");
|
||||||
|
assert_eq!(store2.friend_count().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_wipe_commands() {
|
||||||
|
assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages));
|
||||||
|
assert_eq!(parse_slash(" /wipe "), Some(WipeKind::Messages));
|
||||||
|
assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All));
|
||||||
|
assert_eq!(parse_slash("/wipe-all\n"), Some(WipeKind::All));
|
||||||
|
assert_eq!(parse_slash("wipe"), None);
|
||||||
|
assert_eq!(parse_slash("/rotate"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_requires_typing_not_enter() {
|
||||||
|
let mut p = WipePrompt::messages();
|
||||||
|
assert_eq!(p.on_esc(), WipeDecision::Cancel);
|
||||||
|
assert_eq!(
|
||||||
|
p.on_char('\n'),
|
||||||
|
WipeDecision::Pending,
|
||||||
|
"Enter must not wipe"
|
||||||
|
);
|
||||||
|
for c in "WIP".chars() {
|
||||||
|
assert_eq!(p.on_char(c), WipeDecision::Pending);
|
||||||
|
}
|
||||||
|
assert_eq!(p.on_char('E'), WipeDecision::Confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_all_requires_typing_wipeall() {
|
||||||
|
let mut p = WipePrompt::all();
|
||||||
|
assert_eq!(p.on_char('\n'), WipeDecision::Pending);
|
||||||
|
for c in "WIPEAL".chars() {
|
||||||
|
assert_eq!(p.on_char(c), WipeDecision::Pending);
|
||||||
|
}
|
||||||
|
assert_eq!(p.on_char('L'), WipeDecision::Confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_prompt_text_matches_spec() {
|
||||||
|
let m = wipe_screen_text(WipeKind::Messages);
|
||||||
|
assert!(m.contains("Wipe message log?"));
|
||||||
|
assert!(m.contains("Identity key and friends stay."));
|
||||||
|
assert!(m.contains("Type WIPE to confirm"));
|
||||||
|
assert!(m.contains("Esc to cancel"));
|
||||||
|
let a = wipe_screen_text(WipeKind::All);
|
||||||
|
assert!(a.contains("Wipe ALL local data?"));
|
||||||
|
assert!(a.contains("This deletes your identity key. You become a new person."));
|
||||||
|
assert!(a.contains("Type WIPEALL to confirm"));
|
||||||
|
assert!(a.contains("Esc to cancel"));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue