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",
|
"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]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
|
@ -762,6 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2595,9 +2608,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onionwire"
|
name = "onionwire"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"arti-client",
|
"arti-client",
|
||||||
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures",
|
"futures",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
|
|
@ -2789,6 +2804,17 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "onionwire"
|
name = "onionwire"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
|
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"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
snow = "0.10"
|
snow = "0.10"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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 |
|
| `F2` | Share: terminal QR + payload |
|
||||||
| `F3` | Paste a friend’s payload |
|
| `F3` | Paste a friend’s payload |
|
||||||
| `F4` | Rotate **onion** (locator only) |
|
| `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 |
|
| `Esc` | Close overlay / back to Main / clear composer |
|
||||||
| `Ctrl-Q` | Quit |
|
| `Ctrl-Q` | Quit |
|
||||||
|
|
||||||
|
|
@ -149,7 +150,37 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
|
||||||
|
|
||||||
## Fail closed
|
## 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
|
## 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.
|
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
|
## Out of v1
|
||||||
|
|
||||||
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher.
|
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 dispatch;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod hs;
|
pub mod hs;
|
||||||
|
|
@ -6,6 +7,7 @@ pub mod node;
|
||||||
pub mod pay;
|
pub mod pay;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
|
pub mod ratelimit;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
mod store;
|
mod store;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|
|
||||||
89
src/node.rs
89
src/node.rs
|
|
@ -16,6 +16,7 @@ use crate::loc;
|
||||||
use crate::pay;
|
use crate::pay;
|
||||||
use crate::profile;
|
use crate::profile;
|
||||||
use crate::qr;
|
use crate::qr;
|
||||||
|
use crate::ratelimit::TokenBucket;
|
||||||
use crate::session::{self, Keys};
|
use crate::session::{self, Keys};
|
||||||
use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store};
|
use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store};
|
||||||
use crate::wallet::{self, Wallet};
|
use crate::wallet::{self, Wallet};
|
||||||
|
|
@ -42,8 +43,9 @@ pub struct Node {
|
||||||
client: Client,
|
client: Client,
|
||||||
hs: Mutex<Option<HsHandle>>,
|
hs: Mutex<Option<HsHandle>>,
|
||||||
onion: Mutex<String>,
|
onion: Mutex<String>,
|
||||||
keys: Keys,
|
keys: Mutex<Keys>,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
|
incoming_limit: Mutex<TokenBucket>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Node {
|
impl Node {
|
||||||
|
|
@ -69,8 +71,9 @@ impl Node {
|
||||||
client,
|
client,
|
||||||
hs: Mutex::new(None),
|
hs: Mutex::new(None),
|
||||||
onion: Mutex::new(onion),
|
onion: Mutex::new(onion),
|
||||||
keys,
|
keys: Mutex::new(keys),
|
||||||
wallet: Wallet::from_env(),
|
wallet: Wallet::from_env(),
|
||||||
|
incoming_limit: Mutex::new(TokenBucket::default()),
|
||||||
});
|
});
|
||||||
let rend = spawn_rend(Arc::clone(&node), rend);
|
let rend = spawn_rend(Arc::clone(&node), rend);
|
||||||
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, 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()
|
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] {
|
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 {
|
pub fn arti_dir(&self) -> PathBuf {
|
||||||
|
|
@ -94,8 +104,48 @@ impl Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn qr_payload(&self) -> Result<String, String> {
|
pub fn qr_payload(&self) -> Result<String, String> {
|
||||||
qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk)
|
let k = self.keys()?;
|
||||||
.map_err(|e| e.to_string())
|
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> {
|
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.
|
/// 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> {
|
pub async fn push_self_profile(&self, friend_pk: &[u8]) -> Result<(), String> {
|
||||||
let me = self.self_profile()?;
|
let me = self.self_profile()?;
|
||||||
|
let k = self.keys()?;
|
||||||
let prf = profile::sign(
|
let prf = profile::sign(
|
||||||
&self.keys.identity_sk,
|
&k.identity_sk,
|
||||||
&me.display_name,
|
&me.display_name,
|
||||||
&me.bio,
|
&me.bio,
|
||||||
&me.xmr_addr,
|
&me.xmr_addr,
|
||||||
|
|
@ -214,7 +265,8 @@ impl Node {
|
||||||
memo: &str,
|
memo: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let address = self.invoice_address().await?;
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let pt = pay::encode_invoice(&inv);
|
let pt = pay::encode_invoice(&inv);
|
||||||
self.send_once(friend_pk, &pt).await?;
|
self.send_once(friend_pk, &pt).await?;
|
||||||
|
|
@ -264,7 +316,8 @@ impl Node {
|
||||||
.transfer(&address, amount)
|
.transfer(&address, amount)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let pt = pay::encode_receipt(&rcp);
|
let pt = pay::encode_receipt(&rcp);
|
||||||
self.send_once(friend_pk, &pt).await?;
|
self.send_once(friend_pk, &pt).await?;
|
||||||
|
|
@ -360,7 +413,8 @@ impl Node {
|
||||||
*self.onion.lock().map_err(|e| e.to_string())? = onion.clone();
|
*self.onion.lock().map_err(|e| e.to_string())? = onion.clone();
|
||||||
|
|
||||||
let ts = unix_now();
|
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 loc_pt = loc::encode(&loc);
|
||||||
let friends = self
|
let friends = self
|
||||||
.store
|
.store
|
||||||
|
|
@ -452,8 +506,8 @@ impl Node {
|
||||||
.connect((onion, HS_PORT))
|
.connect((onion, HS_PORT))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?;
|
.map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?;
|
||||||
let mut sess =
|
let keys = self.keys()?;
|
||||||
session::handshake_initiator(&mut stream, &self.keys, pinned_id, remote_prekey)
|
let mut sess = session::handshake_initiator(&mut stream, &keys, pinned_id, remote_prekey)
|
||||||
.await
|
.await
|
||||||
.map_err(session_err)?;
|
.map_err(session_err)?;
|
||||||
let ct = sess.encrypt(plaintext).map_err(session_err)?;
|
let ct = sess.encrypt(plaintext).map_err(session_err)?;
|
||||||
|
|
@ -468,7 +522,8 @@ impl Node {
|
||||||
S: AsyncRead + AsyncWrite + Unpin,
|
S: AsyncRead + AsyncWrite + Unpin,
|
||||||
{
|
{
|
||||||
let store = &self.store;
|
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 {
|
let Ok(g) = store.lock() else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
@ -630,6 +685,16 @@ fn spawn_rend(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut requests = std::pin::pin!(handle_rend_requests(rend));
|
let mut requests = std::pin::pin!(handle_rend_requests(rend));
|
||||||
while let Some(req) = requests.next().await {
|
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);
|
let serve = Arc::clone(&node);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let Ok(mut stream) = req.accept(Connected::new_empty()).await else {
|
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"))?;
|
mkdir_700(&home.join("arti"))?;
|
||||||
let db_path = home.join("onionwire.db");
|
let db_path = home.join("onionwire.db");
|
||||||
let conn = Connection::open(&db_path)?;
|
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(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
@ -164,12 +168,43 @@ impl Store {
|
||||||
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
|
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 };
|
let store = Self { conn };
|
||||||
store.migrate()?;
|
store.migrate()?;
|
||||||
store.ensure_self()?;
|
store.ensure_self()?;
|
||||||
Ok(store)
|
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<()> {
|
fn migrate(&self) -> Result<()> {
|
||||||
self.add_column_if_missing(
|
self.add_column_if_missing(
|
||||||
"friends",
|
"friends",
|
||||||
|
|
|
||||||
296
src/tui.rs
296
src/tui.rs
|
|
@ -68,6 +68,8 @@ OnionWire keys\n\
|
||||||
/profile edit name, bio, Monero address\n\
|
/profile edit name, bio, Monero address\n\
|
||||||
/pay <xmr> [memo] invoice to receive\n\
|
/pay <xmr> [memo] invoice to receive\n\
|
||||||
/tip <xmr> [memo] pay selected friend\n\
|
/tip <xmr> [memo] pay selected friend\n\
|
||||||
|
/backup /path encrypted identity export\n\
|
||||||
|
/restore /path overwrite self keys\n\
|
||||||
? this help\n\
|
? this help\n\
|
||||||
Ctrl-Q quit\n\
|
Ctrl-Q quit\n\
|
||||||
\n\
|
\n\
|
||||||
|
|
@ -150,6 +152,8 @@ pub enum SlashCmd {
|
||||||
Who,
|
Who,
|
||||||
Pay { atomic: String, memo: String },
|
Pay { atomic: String, memo: String },
|
||||||
Tip { atomic: String, memo: String },
|
Tip { atomic: String, memo: String },
|
||||||
|
Backup { path: String },
|
||||||
|
Restore { path: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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 });
|
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
|
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)> {
|
fn parse_amount_cmd(rest: &str) -> Option<(String, String)> {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
if rest.is_empty() {
|
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> {
|
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 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);
|
||||||
ratatui::restore();
|
restore_terminal();
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,12 +527,38 @@ struct App {
|
||||||
|
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Main,
|
Main,
|
||||||
Share { art: String, payload: String },
|
Share {
|
||||||
Paste { buf: String, err: Option<String> },
|
art: String,
|
||||||
Approve { payload: QrPayload },
|
payload: String,
|
||||||
Rotate { prompt: RotatePrompt },
|
},
|
||||||
Wipe { kind: WipeKind, prompt: WipePrompt },
|
Paste {
|
||||||
Profile { editor: ProfileEditor },
|
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 {
|
impl App {
|
||||||
|
|
@ -545,6 +679,22 @@ impl App {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
tip_cmd = Some((atomic, memo));
|
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 => {}
|
None => {}
|
||||||
},
|
},
|
||||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
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 {
|
if let Some(kind) = wipe_confirm {
|
||||||
return self.confirm_wipe(kind);
|
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> {
|
fn confirm_rotate(&mut self) -> Result<(), String> {
|
||||||
let node = Arc::clone(&self.node);
|
let node = Arc::clone(&self.node);
|
||||||
match self.rt.block_on(node.rotate()) {
|
match self.rt.block_on(node.rotate()) {
|
||||||
|
|
@ -855,6 +1096,10 @@ impl App {
|
||||||
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()),
|
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
||||||
Screen::Profile { editor } => draw_profile(f, editor),
|
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 {
|
if self.help_open {
|
||||||
draw_text_overlay(f, "? help", help_overlay_text());
|
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",
|
WipeKind::All => "type WIPEALL Esc cancel ? help",
|
||||||
},
|
},
|
||||||
Screen::Profile { .. } => "Enter save Tab field 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) {
|
fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let lines = text.lines().count() as u16 + 2;
|
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.pubkey, pk(1));
|
||||||
assert_eq!(f.prekey, spk);
|
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();
|
let help = help_overlay_text();
|
||||||
assert!(!help.is_empty());
|
assert!(!help.is_empty());
|
||||||
for needle in [
|
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:?}");
|
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::Chat.prev(), Pane::Roster);
|
||||||
assert_eq!(Pane::Composer.prev(), Pane::Chat);
|
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