feat: incoming rate limit, identity backup, v0.2.0
All checks were successful
ci / test (push) Successful in 4m11s

App-level token bucket on rend accepts (30/60s, burst 10), panic-safe TUI restore, sqlite WAL + integrity_check fail-closed. Encrypted owbak1 identity backup/restore. README and threat model cover profile, XMR sidecar, backup, mixed-version frames.
This commit is contained in:
Sirius DevOps 2026-09-10 14:50:01 -04:00
parent 1a9b54d138
commit e63203ff56
No known key found for this signature in database
14 changed files with 809 additions and 26 deletions

28
Cargo.lock generated
View file

@ -134,6 +134,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -762,6 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -2595,9 +2608,11 @@ dependencies = [
[[package]]
name = "onionwire"
version = "0.1.2"
version = "0.2.0"
dependencies = [
"argon2",
"arti-client",
"chacha20poly1305",
"ed25519-dalek",
"futures",
"qrcode",
@ -2789,6 +2804,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"

View file

@ -1,6 +1,6 @@
[package]
name = "onionwire"
version = "0.1.2"
version = "0.2.0"
edition = "2024"
rust-version = "1.91"
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
@ -24,6 +24,8 @@ ratatui = { version = "0.30.2", default-features = false, features = ["crossterm
x25519-dalek = { version = "2", features = ["static_secrets"] }
snow = "0.10"
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
[dev-dependencies]
tempfile = "3"

View file

@ -126,7 +126,8 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
| `F2` | Share: terminal QR + payload |
| `F3` | Paste a friends payload |
| `F4` | Rotate **onion** (locator only) |
| Enter | Run `/wipe` or `/wipe-all` from the composer |
| `F5` | Selected friends profile (`/who`) |
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
| `Esc` | Close overlay / back to Main / clear composer |
| `Ctrl-Q` | Quit |
@ -149,7 +150,37 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
## Fail closed
If a peers onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server.
If a peers onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
## Profile
`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored.
## Monero sidecar
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`:
```bash
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
```
Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so.
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
- `/tip <xmr> [memo]` — pay the selected friends profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
## Backup / restore
Losing `identity_sk` loses every friend relationship.
- `/backup /path` — type `BACKUP`, passphrase twice. Writes `owbak1` (Argon2id + ChaCha20-Poly1305). Onion is **not** in the file (locator is disposable; F4 after restore if needed).
- `/restore /path` — type `RESTORE`, passphrase. Overwrites self keys. **Does not rewrite the roster** — you may become a different person talking to old friends.
Treat the backup file like the sqlite db.
## Mixed versions
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
## Wipe

View file

@ -22,6 +22,22 @@ The message log, identity secret key, and friend public keys sit on disk unencry
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.
## Profile is friend-visible, not a directory
A signed `prf` frame is shown to people who already have a session with you. Apply only for existing friends. There is still no name lookup, DHT, or public profile server. Anyone who already has a Noise session can see the profile you send them; that is not confidentiality against that friend.
## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the users wallet policy.
## Backup file is the identity
`/backup` writes the identity and Noise static secrets. Treat the file like `onionwire.db`. Restore overwrites self keys and does **not** rewrite the friends table.
## Rate limit is availability, not anonymity
Incoming rendezvous accepts are token-bucket limited (30/60s, burst 10). Excess is dropped without handshake. That is a DoS/availability control. It does not hide that you run an onion, and it is not a traffic-analysis defense.
## Out of v1
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher.

96
src/backup.rs Normal file
View file

@ -0,0 +1,96 @@
//! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
pub const MAGIC: &[u8] = b"owbak1";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
const PLAIN_LEN: usize = KEY_LEN * 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupKeys {
pub identity_sk: [u8; 32],
pub identity_pk: [u8; 32],
pub prekey_sk: [u8; 32],
pub prekey_pk: [u8; 32],
}
impl BackupKeys {
pub fn to_bytes(&self) -> [u8; PLAIN_LEN] {
let mut out = [0u8; PLAIN_LEN];
out[0..32].copy_from_slice(&self.identity_sk);
out[32..64].copy_from_slice(&self.identity_pk);
out[64..96].copy_from_slice(&self.prekey_sk);
out[96..128].copy_from_slice(&self.prekey_pk);
out
}
pub fn from_bytes(b: &[u8]) -> Result<Self, String> {
if b.len() != PLAIN_LEN {
return Err("backup plaintext length".into());
}
Ok(Self {
identity_sk: b[0..32].try_into().unwrap(),
identity_pk: b[32..64].try_into().unwrap(),
prekey_sk: b[64..96].try_into().unwrap(),
prekey_pk: b[96..128].try_into().unwrap(),
})
}
}
fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> {
if passphrase.is_empty() {
return Err("empty passphrase".into());
}
if salt.len() != SALT_LEN {
return Err("salt length".into());
}
let params = Params::new(19_456, 2, 1, Some(32)).map_err(|e| e.to_string())?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| e.to_string())?;
Ok(key)
}
pub fn seal(passphrase: &str, keys: &BackupKeys) -> Result<Vec<u8>, String> {
let mut salt = [0u8; SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let key = kdf(passphrase, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
if nonce.len() != NONCE_LEN {
return Err("nonce length".into());
}
let ct = cipher
.encrypt(&nonce, keys.to_bytes().as_ref())
.map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(MAGIC.len() + SALT_LEN + NONCE_LEN + ct.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
let min = MAGIC.len() + SALT_LEN + NONCE_LEN + 16;
if blob.len() < min || !blob.starts_with(MAGIC) {
return Err("not an onionwire backup".into());
}
let salt = &blob[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_off = MAGIC.len() + SALT_LEN;
let nonce = Nonce::from_slice(&blob[nonce_off..nonce_off + NONCE_LEN]);
let ct = &blob[nonce_off + NONCE_LEN..];
let key = kdf(passphrase, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let pt = cipher
.decrypt(nonce, ct)
.map_err(|_| "wrong passphrase or corrupt backup".to_string())?;
BackupKeys::from_bytes(&pt)
}

View file

@ -1,3 +1,4 @@
pub mod backup;
pub mod dispatch;
pub mod frame;
pub mod hs;
@ -6,6 +7,7 @@ pub mod node;
pub mod pay;
pub mod profile;
pub mod qr;
pub mod ratelimit;
pub mod session;
mod store;
pub mod tui;

View file

@ -16,6 +16,7 @@ use crate::loc;
use crate::pay;
use crate::profile;
use crate::qr;
use crate::ratelimit::TokenBucket;
use crate::session::{self, Keys};
use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store};
use crate::wallet::{self, Wallet};
@ -42,8 +43,9 @@ pub struct Node {
client: Client,
hs: Mutex<Option<HsHandle>>,
onion: Mutex<String>,
keys: Keys,
keys: Mutex<Keys>,
wallet: Wallet,
incoming_limit: Mutex<TokenBucket>,
}
impl Node {
@ -69,8 +71,9 @@ impl Node {
client,
hs: Mutex::new(None),
onion: Mutex::new(onion),
keys,
keys: Mutex::new(keys),
wallet: Wallet::from_env(),
incoming_limit: Mutex::new(TokenBucket::default()),
});
let rend = spawn_rend(Arc::clone(&node), rend);
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend });
@ -81,8 +84,15 @@ impl Node {
self.onion.lock().map(|g| g.clone()).unwrap_or_default()
}
fn keys(&self) -> Result<Keys, String> {
self.keys
.lock()
.map(|g| g.clone())
.map_err(|e| e.to_string())
}
pub fn identity_pk(&self) -> [u8; 32] {
self.keys.identity_pk
self.keys().map(|k| k.identity_pk).unwrap_or([0; 32])
}
pub fn arti_dir(&self) -> PathBuf {
@ -94,8 +104,48 @@ impl Node {
}
pub fn qr_payload(&self) -> Result<String, String> {
qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk)
.map_err(|e| e.to_string())
let k = self.keys()?;
qr::encode(&k.identity_sk, &self.onion(), &k.prekey_pk).map_err(|e| e.to_string())
}
pub fn write_backup(&self, path: &str, passphrase: &str) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let k = self.keys()?;
let blob = crate::backup::seal(
passphrase,
&crate::backup::BackupKeys {
identity_sk: k.identity_sk,
identity_pk: k.identity_pk,
prekey_sk: k.prekey_sk,
prekey_pk: k.prekey_pk,
},
)?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| e.to_string())?;
f.write_all(&blob).map_err(|e| e.to_string())
}
pub fn restore_backup(&self, path: &str, passphrase: &str) -> Result<(), String> {
let blob = std::fs::read(path).map_err(|e| e.to_string())?;
let k = crate::backup::open(passphrase, &blob)?;
self.store
.lock()
.map_err(|e| e.to_string())?
.replace_identity_keys(&k.identity_sk, &k.identity_pk, &k.prekey_sk, &k.prekey_pk)
.map_err(|e| e.to_string())?;
*self.keys.lock().map_err(|e| e.to_string())? = Keys {
identity_sk: k.identity_sk,
identity_pk: k.identity_pk,
prekey_sk: k.prekey_sk,
prekey_pk: k.prekey_pk,
};
Ok(())
}
pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> {
@ -187,8 +237,9 @@ impl Node {
/// 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 k = self.keys()?;
let prf = profile::sign(
&self.keys.identity_sk,
&k.identity_sk,
&me.display_name,
&me.bio,
&me.xmr_addr,
@ -214,7 +265,8 @@ impl Node {
memo: &str,
) -> Result<(), String> {
let address = self.invoice_address().await?;
let inv = pay::sign_invoice(&self.keys.identity_sk, atomic, &address, memo, unix_now())
let k = self.keys()?;
let inv = pay::sign_invoice(&k.identity_sk, atomic, &address, memo, unix_now())
.map_err(|e| e.to_string())?;
let pt = pay::encode_invoice(&inv);
self.send_once(friend_pk, &pt).await?;
@ -264,7 +316,8 @@ impl Node {
.transfer(&address, amount)
.await
.map_err(|e| e.to_string())?;
let rcp = pay::sign_receipt(&self.keys.identity_sk, &txid, atomic, &address, unix_now())
let k = self.keys()?;
let rcp = pay::sign_receipt(&k.identity_sk, &txid, atomic, &address, unix_now())
.map_err(|e| e.to_string())?;
let pt = pay::encode_receipt(&rcp);
self.send_once(friend_pk, &pt).await?;
@ -360,7 +413,8 @@ impl Node {
*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 k = self.keys()?;
let loc = loc::sign(&k.identity_sk, &onion, ts).map_err(|e| e.to_string())?;
let loc_pt = loc::encode(&loc);
let friends = self
.store
@ -452,8 +506,8 @@ impl Node {
.connect((onion, HS_PORT))
.await
.map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?;
let mut sess =
session::handshake_initiator(&mut stream, &self.keys, pinned_id, remote_prekey)
let keys = self.keys()?;
let mut sess = session::handshake_initiator(&mut stream, &keys, pinned_id, remote_prekey)
.await
.map_err(session_err)?;
let ct = sess.encrypt(plaintext).map_err(session_err)?;
@ -468,7 +522,8 @@ impl Node {
S: AsyncRead + AsyncWrite + Unpin,
{
let store = &self.store;
let mut sess = session::handshake_responder(stream, &self.keys, |spk| {
let keys = self.keys()?;
let mut sess = session::handshake_responder(stream, &keys, |spk| {
let Ok(g) = store.lock() else {
return None;
};
@ -630,6 +685,16 @@ fn spawn_rend(
tokio::spawn(async move {
let mut requests = std::pin::pin!(handle_rend_requests(rend));
while let Some(req) = requests.next().await {
let allow = node
.incoming_limit
.lock()
.map(|mut b| b.try_acquire())
.unwrap_or(false);
if !allow {
eprintln!("rate-limit drop");
drop(req);
continue;
}
let serve = Arc::clone(&node);
tokio::spawn(async move {
let Ok(mut stream) = req.accept(Connected::new_empty()).await else {

45
src/ratelimit.rs Normal file
View file

@ -0,0 +1,45 @@
//! App-level token bucket for incoming rendezvous accepts.
use std::time::{Duration, Instant};
pub struct TokenBucket {
rate_per_sec: f64,
burst: f64,
tokens: f64,
last: Instant,
}
impl TokenBucket {
/// `count` tokens replenished over `window`, starting full up to `burst`.
pub fn new(count: u32, window: Duration, burst: u32) -> Self {
let secs = window.as_secs_f64().max(f64::EPSILON);
Self {
rate_per_sec: f64::from(count) / secs,
burst: f64::from(burst),
tokens: f64::from(burst),
last: Instant::now(),
}
}
pub fn try_acquire(&mut self) -> bool {
self.try_acquire_at(Instant::now())
}
pub fn try_acquire_at(&mut self, now: Instant) -> bool {
let elapsed = now.saturating_duration_since(self.last).as_secs_f64();
self.last = now;
self.tokens = (self.tokens + elapsed * self.rate_per_sec).min(self.burst);
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
impl Default for TokenBucket {
fn default() -> Self {
Self::new(30, Duration::from_secs(60), 10)
}
}

View file

@ -104,6 +104,10 @@ impl Store {
mkdir_700(&home.join("arti"))?;
let db_path = home.join("onionwire.db");
let conn = Connection::open(&db_path)?;
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if !journal.eq_ignore_ascii_case("wal") {
return Err(Error(format!("journal_mode WAL failed: {journal}")));
}
conn.execute_batch(
"
PRAGMA foreign_keys = ON;
@ -164,12 +168,43 @@ impl Store {
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
",
)?;
let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if check != "ok" {
return Err(Error(format!("integrity_check: {check}")));
}
let store = Self { conn };
store.migrate()?;
store.ensure_self()?;
Ok(store)
}
pub fn journal_mode(&self) -> Result<String> {
self.conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.map_err(Into::into)
}
pub fn replace_identity_keys(
&self,
identity_sk: &[u8],
identity_pk: &[u8],
prekey_sk: &[u8],
prekey_pk: &[u8],
) -> Result<()> {
if identity_sk.len() != 32
|| identity_pk.len() != 32
|| prekey_sk.len() != 32
|| prekey_pk.len() != 32
{
return Err(Error("identity key length".into()));
}
self.conn.execute(
"UPDATE self SET identity_sk = ?1, identity_pk = ?2, prekey_sk = ?3, prekey_pk = ?4 WHERE id = 1",
params![identity_sk, identity_pk, prekey_sk, prekey_pk],
)?;
Ok(())
}
fn migrate(&self) -> Result<()> {
self.add_column_if_missing(
"friends",

View file

@ -68,6 +68,8 @@ OnionWire keys\n\
/profile edit name, bio, Monero address\n\
/pay <xmr> [memo] invoice to receive\n\
/tip <xmr> [memo] pay selected friend\n\
/backup /path encrypted identity export\n\
/restore /path overwrite self keys\n\
? this help\n\
Ctrl-Q quit\n\
\n\
@ -150,6 +152,8 @@ pub enum SlashCmd {
Who,
Pay { atomic: String, memo: String },
Tip { atomic: String, memo: String },
Backup { path: String },
Restore { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -261,9 +265,28 @@ pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
{
return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Tip { atomic, memo });
}
if let Some(rest) = s.strip_prefix("/backup")
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return parse_path_cmd(rest).map(|path| SlashCmd::Backup { path });
}
if let Some(rest) = s.strip_prefix("/restore")
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path });
}
None
}
fn parse_path_cmd(rest: &str) -> Option<String> {
let path = rest.trim();
if path.is_empty() {
None
} else {
Some(path.to_string())
}
}
fn parse_amount_cmd(rest: &str) -> Option<(String, String)> {
let rest = rest.trim();
if rest.is_empty() {
@ -392,11 +415,96 @@ impl RotatePrompt {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackupKind {
Backup,
Restore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackupDecision {
Pending,
Confirm,
Cancel,
}
#[derive(Debug)]
pub struct BackupPrompt {
expected: &'static str,
buf: String,
}
impl BackupPrompt {
pub fn backup() -> Self {
Self {
expected: "BACKUP",
buf: String::new(),
}
}
pub fn restore() -> Self {
Self {
expected: "RESTORE",
buf: String::new(),
}
}
pub fn on_esc(&self) -> BackupDecision {
BackupDecision::Cancel
}
pub fn on_char(&mut self, c: char) -> BackupDecision {
if c == '\n' {
return BackupDecision::Pending;
}
if c == '\u{8}' {
self.buf.pop();
return BackupDecision::Pending;
}
if !c.is_ascii_alphabetic() {
return BackupDecision::Pending;
}
self.buf.push(c);
if self.buf == self.expected {
BackupDecision::Confirm
} else {
BackupDecision::Pending
}
}
pub fn typed(&self) -> &str {
&self.buf
}
}
pub fn backup_screen_text() -> &'static str {
"Write encrypted identity backup?\n\
This file is your identity secret. Treat it like the database.\n\
Onion (locator) is not included.\n\
Type BACKUP to confirm Esc to cancel"
}
pub fn restore_screen_text() -> &'static str {
"Restore identity from backup?\n\
This overwrites your self keys. It does not rewrite the roster.\n\
You may become a different person with old friends.\n\
Type RESTORE to confirm Esc to cancel"
}
pub fn restore_terminal() {
ratatui::restore();
}
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
prev(info);
}));
let mut app = App::new(node, rt)?;
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
restore_terminal();
result
}
@ -419,12 +527,38 @@ struct App {
enum Screen {
Main,
Share { art: String, payload: String },
Paste { buf: String, err: Option<String> },
Approve { payload: QrPayload },
Rotate { prompt: RotatePrompt },
Wipe { kind: WipeKind, prompt: WipePrompt },
Profile { editor: ProfileEditor },
Share {
art: String,
payload: String,
},
Paste {
buf: String,
err: Option<String>,
},
Approve {
payload: QrPayload,
},
Rotate {
prompt: RotatePrompt,
},
Wipe {
kind: WipeKind,
prompt: WipePrompt,
},
Profile {
editor: ProfileEditor,
},
ConfirmKeys {
kind: BackupKind,
path: String,
prompt: BackupPrompt,
},
Passphrase {
kind: BackupKind,
path: String,
first: Option<String>,
buf: String,
},
}
impl App {
@ -545,6 +679,22 @@ impl App {
self.composer.clear();
tip_cmd = Some((atomic, memo));
}
Some(SlashCmd::Backup { path }) => {
self.composer.clear();
self.screen = Screen::ConfirmKeys {
kind: BackupKind::Backup,
path,
prompt: BackupPrompt::backup(),
};
}
Some(SlashCmd::Restore { path }) => {
self.composer.clear();
self.screen = Screen::ConfirmKeys {
kind: BackupKind::Restore,
path,
prompt: BackupPrompt::restore(),
};
}
None => {}
},
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
@ -619,6 +769,48 @@ impl App {
}
_ => {}
},
Screen::ConfirmKeys { kind, path, 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) == BackupDecision::Confirm {
self.screen = Screen::Passphrase {
kind,
path: path.clone(),
first: None,
buf: String::new(),
};
}
}
KeyCode::Backspace => {
prompt.on_char('\u{8}');
}
_ => {}
}
}
Screen::Passphrase {
kind,
path,
first,
buf,
} => match key.code {
KeyCode::Esc => self.screen = Screen::Main,
KeyCode::Backspace => {
buf.pop();
}
KeyCode::Enter => {
let kind = *kind;
let path = path.clone();
let first = first.clone();
let pass = buf.clone();
self.submit_passphrase(kind, &path, first.as_deref(), &pass)?;
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
buf.push(c);
}
_ => {}
},
}
if let Some(kind) = wipe_confirm {
return self.confirm_wipe(kind);
@ -719,6 +911,55 @@ impl App {
}
}
fn submit_passphrase(
&mut self,
kind: BackupKind,
path: &str,
first: Option<&str>,
pass: &str,
) -> Result<(), String> {
match kind {
BackupKind::Backup => {
if let Some(first) = first {
if first != pass {
self.alert = Some("passphrases do not match".into());
self.screen = Screen::Main;
return Ok(());
}
match self.node.write_backup(path, pass) {
Ok(()) => {
self.status_note = Some(format!("backup written {path}"));
self.screen = Screen::Main;
}
Err(e) => {
self.alert = Some(e);
self.screen = Screen::Main;
}
}
} else {
self.screen = Screen::Passphrase {
kind,
path: path.to_string(),
first: Some(pass.to_string()),
buf: String::new(),
};
}
}
BackupKind::Restore => match self.node.restore_backup(path, pass) {
Ok(()) => {
self.me_fp = to_hex(&self.node.identity_pk());
self.status_note = Some("identity restored — roster unchanged".into());
self.screen = Screen::Main;
}
Err(e) => {
self.alert = Some(e);
self.screen = Screen::Main;
}
},
}
Ok(())
}
fn confirm_rotate(&mut self) -> Result<(), String> {
let node = Arc::clone(&self.node);
match self.rt.block_on(node.rotate()) {
@ -855,6 +1096,10 @@ impl App {
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),
Screen::ConfirmKeys { kind, prompt, .. } => draw_confirm_keys(f, *kind, prompt.typed()),
Screen::Passphrase {
kind, first, buf, ..
} => draw_passphrase(f, *kind, first.is_some(), buf),
}
if self.help_open {
draw_text_overlay(f, "? help", help_overlay_text());
@ -1094,6 +1339,11 @@ fn footer_hints(screen: &Screen) -> &'static str {
WipeKind::All => "type WIPEALL Esc cancel ? help",
},
Screen::Profile { .. } => "Enter save Tab field Esc cancel ? help",
Screen::ConfirmKeys { kind, .. } => match kind {
BackupKind::Backup => "type BACKUP Esc cancel ? help",
BackupKind::Restore => "type RESTORE Esc cancel ? help",
},
Screen::Passphrase { .. } => "Enter submit Esc cancel ? help",
}
}
@ -1194,6 +1444,38 @@ fn draw_profile(f: &mut Frame, editor: &ProfileEditor) {
);
}
fn draw_confirm_keys(f: &mut Frame, kind: BackupKind, typed: &str) {
let (title, text) = match kind {
BackupKind::Backup => ("(o) backup", backup_screen_text()),
BackupKind::Restore => ("(o) restore", restore_screen_text()),
};
let body = format!("{text}\n\n{typed}");
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block(title)),
f.area(),
);
}
fn draw_passphrase(f: &mut Frame, kind: BackupKind, second: bool, buf: &str) {
let stars: String = buf.chars().map(|_| '*').collect();
let hint = match (kind, second) {
(BackupKind::Backup, false) => "Enter passphrase",
(BackupKind::Backup, true) => "Re-enter passphrase",
(BackupKind::Restore, _) => "Enter passphrase",
};
let body = format!("{hint}\n\n{stars}\n\nEnter submit Esc cancel");
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block("(o) passphrase")),
f.area(),
);
}
fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) {
let area = f.area();
let lines = text.lines().count() as u16 + 2;

135
tests/backup.rs Normal file
View file

@ -0,0 +1,135 @@
//! Encrypted identity backup: owbak1 || salt[16] || nonce[12] || ciphertext.
use onionwire::Store;
use onionwire::backup::{self, BackupKeys};
use onionwire::tui::{
BackupDecision, BackupPrompt, SlashCmd, backup_screen_text, parse_cmd, restore_screen_text,
};
fn keys(tag: u8) -> BackupKeys {
BackupKeys {
identity_sk: [tag; 32],
identity_pk: [tag.wrapping_add(1); 32],
prekey_sk: [tag.wrapping_add(2); 32],
prekey_pk: [tag.wrapping_add(3); 32],
}
}
#[test]
fn roundtrip_keys() {
let k = keys(7);
let blob = backup::seal("correct horse", &k).expect("seal");
assert!(blob.starts_with(b"owbak1"), "magic");
assert_eq!(&blob[0..6], b"owbak1");
let opened = backup::open("correct horse", &blob).expect("open");
assert_eq!(opened.identity_sk, k.identity_sk);
assert_eq!(opened.identity_pk, k.identity_pk);
assert_eq!(opened.prekey_sk, k.prekey_sk);
assert_eq!(opened.prekey_pk, k.prekey_pk);
}
#[test]
fn onion_is_not_in_backup() {
let k = keys(3);
let blob = backup::seal("pw", &k).unwrap();
let onion = b"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
assert!(
!blob.windows(onion.len()).any(|w| w == onion),
"locator must not be in the backup file"
);
}
#[test]
fn wrong_passphrase_fails() {
let blob = backup::seal("right", &keys(1)).unwrap();
assert!(backup::open("wrong", &blob).is_err());
}
#[test]
fn garbage_file_fails() {
assert!(backup::open("pw", b"nope").is_err());
assert!(backup::open("pw", b"owbak1").is_err());
let mut blob = backup::seal("pw", &keys(2)).unwrap();
blob.push(0xff);
assert!(backup::open("pw", &blob).is_err());
}
#[test]
fn restore_overwrites_self_keys_friends_stay() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
store
.upsert_friend(&[9u8; 32], "alice.onion", Some("alice"))
.unwrap();
let old = store.self_identity().unwrap();
let incoming = keys(42);
store
.replace_identity_keys(
&incoming.identity_sk,
&incoming.identity_pk,
&incoming.prekey_sk,
&incoming.prekey_pk,
)
.unwrap();
let me = store.self_identity().unwrap();
assert_eq!(me.identity_sk, incoming.identity_sk);
assert_eq!(me.identity_pk, incoming.identity_pk);
assert_eq!(me.prekey_sk, incoming.prekey_sk);
assert_eq!(me.prekey_pk, incoming.prekey_pk);
assert_eq!(me.onion, old.onion, "onion is locator, not restored");
assert_eq!(store.friend_count().unwrap(), 1);
let f = store.get_friend(&[9u8; 32]).unwrap().unwrap();
assert_eq!(f.petname.as_deref(), Some("alice"));
}
#[test]
fn slash_backup_restore() {
assert_eq!(
parse_cmd("/backup /tmp/id.owbak"),
Some(SlashCmd::Backup {
path: "/tmp/id.owbak".into()
})
);
assert_eq!(
parse_cmd(" /restore /tmp/id.owbak "),
Some(SlashCmd::Restore {
path: "/tmp/id.owbak".into()
})
);
assert_eq!(parse_cmd("/backup"), None);
assert_eq!(parse_cmd("/restore"), None);
}
#[test]
fn backup_requires_typing_not_enter() {
let mut p = BackupPrompt::backup();
assert_eq!(p.on_esc(), BackupDecision::Cancel);
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
for c in "BACKU".chars() {
assert_eq!(p.on_char(c), BackupDecision::Pending);
}
assert_eq!(p.on_char('P'), BackupDecision::Confirm);
}
#[test]
fn restore_requires_typing_restore() {
let mut p = BackupPrompt::restore();
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
for c in "RESTOR".chars() {
assert_eq!(p.on_char(c), BackupDecision::Pending);
}
assert_eq!(p.on_char('E'), BackupDecision::Confirm);
}
#[test]
fn restore_screen_warns_roster_stays() {
let t = restore_screen_text();
assert!(t.contains("does not rewrite the roster"), "{t}");
assert!(t.contains("Type RESTORE to confirm"), "{t}");
let b = backup_screen_text();
assert!(
t.contains("different person") || b.contains("identity"),
"{t}\n{b}"
);
assert!(b.contains("Type BACKUP to confirm"), "{b}");
}

28
tests/ratelimit.rs Normal file
View file

@ -0,0 +1,28 @@
//! Incoming rend accepts: 30 / 60s, burst 10.
use std::time::{Duration, Instant};
use onionwire::ratelimit::TokenBucket;
#[test]
fn burst_allows_then_denies() {
let t0 = Instant::now();
let mut b = TokenBucket::new(30, Duration::from_secs(60), 10);
for _ in 0..10 {
assert!(b.try_acquire_at(t0), "burst of 10 must pass");
}
assert!(!b.try_acquire_at(t0), "11th in the burst must drop");
}
#[test]
fn refill_one_token_after_two_seconds() {
let t0 = Instant::now();
let mut b = TokenBucket::new(30, Duration::from_secs(60), 10);
for _ in 0..10 {
assert!(b.try_acquire_at(t0));
}
// 30 tokens / 60s = 0.5/s → 2s yields one token.
let t1 = t0 + Duration::from_secs(2);
assert!(b.try_acquire_at(t1));
assert!(!b.try_acquire_at(t1));
}

View file

@ -163,3 +163,17 @@ fn friend_prekey_lookup() {
assert_eq!(f.pubkey, pk(1));
assert_eq!(f.prekey, spk);
}
#[test]
fn open_uses_wal_journal() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal");
}
#[test]
fn corrupt_db_fails_closed() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("onionwire.db"), b"not a sqlite database").unwrap();
assert!(Store::open_at(dir.path()).is_err());
}

View file

@ -39,7 +39,8 @@ fn help_overlay_lists_core_bindings() {
let help = help_overlay_text();
assert!(!help.is_empty());
for needle in [
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "Ctrl-Q", "?",
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q",
"?",
] {
assert!(help.contains(needle), "help overlay missing {needle:?}");
}
@ -156,3 +157,8 @@ fn pane_focus_cycles_roster_chat_composer() {
assert_eq!(Pane::Chat.prev(), Pane::Roster);
assert_eq!(Pane::Composer.prev(), Pane::Chat);
}
#[test]
fn restore_terminal_is_callable_without_panic() {
onionwire::tui::restore_terminal();
}