feat: incoming rate limit, identity backup, v0.2.0
All checks were successful
ci / test (push) Successful in 4m11s
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:
parent
1a9b54d138
commit
e63203ff56
14 changed files with 809 additions and 26 deletions
28
Cargo.lock
generated
28
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -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 friend’s payload |
|
||||
| `F4` | Rotate **onion** (locator only) |
|
||||
| Enter | Run `/wipe` or `/wipe-all` from the composer |
|
||||
| `F5` | Selected friend’s 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 peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server.
|
||||
If a peer’s 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 friend’s 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 user’s 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
96
src/backup.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
93
src/node.rs
93
src/node.rs
|
|
@ -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,10 +506,10 @@ 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)
|
||||
.await
|
||||
.map_err(session_err)?;
|
||||
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)?;
|
||||
frame::write_frame(&mut stream, &ct)
|
||||
.await
|
||||
|
|
@ -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
45
src/ratelimit.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
35
src/store.rs
35
src/store.rs
|
|
@ -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",
|
||||
|
|
|
|||
296
src/tui.rs
296
src/tui.rs
|
|
@ -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
135
tests/backup.rs
Normal 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
28
tests/ratelimit.rs
Normal 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));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue