diff --git a/Cargo.lock b/Cargo.lock index 2c85766..f674c34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.3" @@ -20,6 +41,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -345,6 +380,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blanket" version = "0.3.0" @@ -458,6 +502,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -469,6 +524,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -489,6 +557,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", + "zeroize", ] [[package]] @@ -705,6 +774,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.1" @@ -1558,6 +1636,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + [[package]] name = "glob-match" version = "0.2.1" @@ -2517,14 +2605,22 @@ dependencies = [ "ratatui", "rusqlite", "safelog", + "snow", "tempfile", "tokio", "tor-cell", "tor-hsservice", "tor-rtcompat", "tracing-subscriber", + "x25519-dalek", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -2809,6 +2905,29 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "polyval" version = "0.7.3" @@ -2817,7 +2936,7 @@ checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", "cpufeatures 0.3.1", - "universal-hash", + "universal-hash 0.6.1", ] [[package]] @@ -2974,7 +3093,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3205,6 +3324,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3684,6 +3817,23 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + [[package]] name = "socket2" version = "0.6.5" @@ -4839,9 +4989,9 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ - "aes", + "aes 0.9.3", "base64ct", - "ctr", + "ctr 0.10.1", "curve25519-dalek", "der-parser", "derive-deftly", @@ -5058,7 +5208,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ - "aes", + "aes 0.9.3", "amplify", "async-trait", "asynchronous-codec", @@ -5068,7 +5218,7 @@ dependencies = [ "cfg-if", "cipher 0.5.2", "coarsetime", - "ctr", + "ctr 0.10.1", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -5083,7 +5233,7 @@ dependencies = [ "nonany", "oneshot-fused-workaround", "pin-project", - "polyval", + "polyval 0.7.3", "postage", "rand 0.10.2", "rand_core 0.10.1", @@ -5387,6 +5537,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" @@ -5397,6 +5557,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" @@ -5696,6 +5862,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 41db803..a814cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ tor-hsservice = "0.46" tor-rtcompat = { version = "0.46", features = ["tokio"] } qrcode = { version = "0.14.1", default-features = false } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +snow = "0.10" [dev-dependencies] tempfile = "3" diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..8dbb67c --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,72 @@ +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const VERSION: u8 = 1; +pub const MAX_FRAME: usize = 65535; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error(pub String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error {} + +pub fn encode(body: &[u8]) -> Result> { + if body.len() > MAX_FRAME { + return Err(Error("frame too large".into())); + } + let mut out = Vec::with_capacity(5 + body.len()); + out.push(VERSION); + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(body); + Ok(out) +} + +pub fn decode(data: &[u8]) -> Result> { + if data.len() < 5 { + return Err(Error("short frame".into())); + } + if data[0] != VERSION { + return Err(Error("bad version".into())); + } + let len = u32::from_be_bytes(data[1..5].try_into().expect("4 bytes")) as usize; + if data.len() != 5 + len { + return Err(Error("length mismatch".into())); + } + if len > MAX_FRAME { + return Err(Error("frame too large".into())); + } + Ok(data[5..].to_vec()) +} + +pub async fn write_frame(w: &mut W, body: &[u8]) -> Result<()> { + let framed = encode(body)?; + w.write_all(&framed) + .await + .map_err(|e| Error(e.to_string()))?; + w.flush().await.map_err(|e| Error(e.to_string())) +} + +pub async fn read_frame(r: &mut R) -> Result> { + let mut hdr = [0u8; 5]; + r.read_exact(&mut hdr) + .await + .map_err(|e| Error(e.to_string()))?; + if hdr[0] != VERSION { + return Err(Error("bad version".into())); + } + let len = u32::from_be_bytes(hdr[1..5].try_into().expect("4 bytes")) as usize; + if len > MAX_FRAME { + return Err(Error("frame too large".into())); + } + let mut body = vec![0u8; len]; + r.read_exact(&mut body) + .await + .map_err(|e| Error(e.to_string()))?; + Ok(body) +} diff --git a/src/hs.rs b/src/hs.rs new file mode 100644 index 0000000..10f9212 --- /dev/null +++ b/src/hs.rs @@ -0,0 +1,78 @@ +//! In-process Arti onion-service helpers (no C-tor). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use arti_client::config::TorClientConfigBuilder; +use arti_client::{TorClient, TorClientConfig}; +use futures::StreamExt; +use safelog::DisplayRedacted; +use tor_hsservice::status::State; +use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService}; +use tor_rtcompat::PreferredRuntime; + +pub const HS_PORT: u16 = 80; + +pub type Client = Arc>; + +pub fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig { + std::fs::create_dir_all(state_dir).expect("state dir"); + std::fs::create_dir_all(cache_dir).expect("cache dir"); + let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir); + builder.storage().permissions().dangerously_trust_everyone(); + builder.build().expect("TorClientConfig") +} + +pub async fn bootstrapped( + state_dir: &std::path::Path, + cache_dir: &std::path::Path, +) -> Result { + let cfg = client_config(state_dir, cache_dir); + TorClient::create_bootstrapped(cfg) + .await + .map_err(|e| format!("Arti bootstrap failed — fail closed, no C-tor fallback: {e}")) +} + +pub fn hs_config(nickname: &str) -> Result { + let nickname = HsNickname::new(nickname.to_string()).map_err(|e| format!("HsNickname: {e}"))?; + OnionServiceConfig::builder() + .nickname(nickname) + .build() + .map_err(|e| format!("OnionServiceConfig: {e}")) +} + +pub fn onion_string(svc: &RunningOnionService) -> Result { + let id = svc + .onion_address() + .ok_or_else(|| "onion identity missing from keystore".to_string())?; + Ok(id.display_unredacted().to_string()) +} + +pub async fn wait_until_published(svc: &RunningOnionService, label: &str) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(180); + let mut events = svc.status_events(); + loop { + let st = svc.status(); + eprintln!("{label} hs status: {:?}", st.state()); + match st.state() { + State::Running | State::DegradedReachable => return Ok(()), + State::Broken => { + return Err(format!( + "{label}: onion service broken: {:?}", + st.current_problem() + )); + } + _ => {} + } + if Instant::now() >= deadline { + return Err(format!( + "{label}: onion service did not publish within 180s: {:?}", + st.state() + )); + } + tokio::select! { + _ = events.next() => {} + _ = tokio::time::sleep(Duration::from_secs(2)) => {} + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 70d5bed..565df5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ +pub mod frame; +pub mod hs; +pub mod node; pub mod qr; +pub mod session; mod store; pub mod tui; -pub use store::{Friend, SelfIdentity, Store}; +pub use store::{Friend, Message, SelfIdentity, Store}; diff --git a/src/node.rs b/src/node.rs new file mode 100644 index 0000000..f43bee0 --- /dev/null +++ b/src/node.rs @@ -0,0 +1,215 @@ +//! Two-node onion session: host HS, Noise IK, persist plaintext locally. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures::StreamExt; +use futures::io::{AsyncRead, AsyncWrite}; +use tor_cell::relaycell::msg::Connected; +use tor_hsservice::{RunningOnionService, handle_rend_requests}; + +use crate::frame; +use crate::hs::{self, Client, HS_PORT}; +use crate::qr; +use crate::session::{self, Keys}; +use crate::store::{Message, Store}; + +pub struct Node { + home: PathBuf, + store: Mutex, + client: Client, + _svc: Arc, + onion: String, + keys: Keys, +} + +impl Node { + pub async fn start(home: PathBuf) -> Result, String> { + let store = Store::open_at(&home).map_err(|e| e.to_string())?; + let me = store.self_identity().map_err(|e| e.to_string())?; + let keys = Keys::from_self(&me).map_err(|e| e.to_string())?; + let state = home.join("arti"); + let cache = home.join("cache"); + let client = hs::bootstrapped(&state, &cache).await?; + let launched = client + .launch_onion_service(hs::hs_config("onionwire")?) + .map_err(|e| format!("launch_onion_service: {e}"))? + .ok_or_else(|| "onion service disabled in config — fail closed".to_string())?; + let (svc, rend) = launched; + let onion = hs::onion_string(&svc)?; + hs::wait_until_published(&svc, &onion).await?; + store.set_onion(&onion).map_err(|e| e.to_string())?; + let node = Arc::new(Self { + home, + store: Mutex::new(store), + client, + _svc: svc, + onion, + keys, + }); + let serve = Arc::clone(&node); + tokio::spawn(async move { + let mut requests = std::pin::pin!(handle_rend_requests(rend)); + while let Some(req) = requests.next().await { + let serve = Arc::clone(&serve); + tokio::spawn(async move { + let Ok(mut stream) = req.accept(Connected::new_empty()).await else { + return; + }; + if let Err(e) = serve.handle_incoming(&mut stream).await { + eprintln!("incoming: {e}"); + } + }); + } + }); + Ok(node) + } + + pub fn onion(&self) -> &str { + &self.onion + } + + pub fn identity_pk(&self) -> [u8; 32] { + self.keys.identity_pk + } + + pub fn arti_dir(&self) -> PathBuf { + self.home.join("arti") + } + + pub fn cache_dir(&self) -> PathBuf { + self.home.join("cache") + } + + pub fn qr_payload(&self) -> Result { + qr::encode(&self.keys.identity_sk, &self.onion, &self.keys.prekey_pk) + .map_err(|e| e.to_string()) + } + + pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> { + let p = qr::decode(raw).map_err(|e| e.to_string())?; + let store = self.store.lock().map_err(|e| e.to_string())?; + store + .upsert_friend(&p.pubkey, &p.onion, None) + .map_err(|e| e.to_string())?; + store + .set_friend_prekey(&p.pubkey, &p.signed_prekey) + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn list_messages(&self, friend_pk: &[u8]) -> Result, String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .list_messages(friend_pk) + .map_err(|e| e.to_string()) + } + + pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> { + let (onion, prekey) = { + let store = self.store.lock().map_err(|e| e.to_string())?; + let f = store + .get_friend(friend_pk) + .map_err(|e| e.to_string())? + .ok_or_else(|| "unknown friend".to_string())?; + if f.prekey.len() != 32 { + return Err("friend missing prekey".into()); + } + (f.onion, f.prekey) + }; + let deadline = Instant::now() + Duration::from_secs(180); + let mut last = None::; + while Instant::now() < deadline { + match self.try_send(&onion, friend_pk, &prekey, plaintext).await { + Ok(()) => { + self.store + .lock() + .map_err(|e| e.to_string())? + .append_message(friend_pk, "out", plaintext) + .map_err(|e| e.to_string())?; + return Ok(()); + } + Err(e) if e.contains("fingerprint mismatch") => return Err(e), + Err(e) => last = Some(e), + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + Err(last.unwrap_or_else(|| "send timed out".into())) + } + + async fn try_send( + &self, + onion: &str, + pinned_id: &[u8], + remote_prekey: &[u8], + plaintext: &[u8], + ) -> Result<(), String> { + let mut stream = self + .client + .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 ct = sess.encrypt(plaintext).map_err(session_err)?; + frame::write_frame(&mut stream, &ct) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + async fn handle_incoming(&self, stream: &mut S) -> Result<(), String> + where + S: AsyncRead + AsyncWrite + Unpin, + { + let store = &self.store; + let mut sess = session::handshake_responder(stream, &self.keys, |spk| { + let Ok(g) = store.lock() else { + return None; + }; + g.get_friend_by_prekey(spk).ok().flatten().map(|f| f.pubkey) + }) + .await + .map_err(session_err)?; + let ct = frame::read_frame(stream).await.map_err(|e| e.to_string())?; + let pt = sess.decrypt(&ct).map_err(session_err)?; + store + .lock() + .map_err(|e| e.to_string())? + .append_message(&sess.peer_identity, "in", &pt) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +fn session_err(e: session::Error) -> String { + if e.is_fingerprint_mismatch() { + crate::tui::fingerprint_mismatch_banner().to_string() + } else { + e.to_string() + } +} + +pub fn dir_contains_bytes(dir: &Path, needle: &[u8]) -> bool { + let mut stack = vec![dir.to_path_buf()]; + while let Some(p) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&p) else { + continue; + }; + for ent in rd.flatten() { + let path = ent.path(); + if path.is_dir() { + stack.push(path); + } else if let Ok(bytes) = std::fs::read(&path) + && bytes.windows(needle.len()).any(|w| w == needle) + { + return true; + } + } + } + false +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..e5e21b6 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,315 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use snow::Builder; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; + +use crate::store::SelfIdentity; + +const PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; +const PROLOGUE: &[u8] = b"onionwire-v1"; +const MSG_BUF: usize = 65535; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error { + mismatch: bool, + msg: String, +} + +impl Error { + fn new(msg: impl Into) -> Self { + Self { + mismatch: false, + msg: msg.into(), + } + } + + fn mismatch(msg: impl Into) -> Self { + Self { + mismatch: true, + msg: msg.into(), + } + } + + pub fn is_fingerprint_mismatch(&self) -> bool { + self.mismatch + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.msg.fmt(f) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: snow::Error) -> Self { + Self::new(e.to_string()) + } +} + +#[derive(Clone)] +pub struct Keys { + pub identity_sk: [u8; 32], + pub identity_pk: [u8; 32], + pub prekey_sk: [u8; 32], + pub prekey_pk: [u8; 32], +} + +impl Keys { + pub fn generate() -> Self { + let signing = SigningKey::generate(&mut OsRng); + let xsk = StaticSecret::random_from_rng(OsRng); + let xpk = X25519Public::from(&xsk); + Self { + identity_sk: signing.to_bytes(), + identity_pk: signing.verifying_key().to_bytes(), + prekey_sk: xsk.to_bytes(), + prekey_pk: xpk.to_bytes(), + } + } + + pub fn from_self(me: &SelfIdentity) -> Result { + Ok(Self { + identity_sk: to_32(&me.identity_sk)?, + identity_pk: to_32(&me.identity_pk)?, + prekey_sk: to_32(&me.prekey_sk)?, + prekey_pk: to_32(&me.prekey_pk)?, + }) + } +} + +pub struct Session { + transport: snow::TransportState, + pub peer_identity: Vec, +} + +impl Session { + pub fn encrypt(&mut self, pt: &[u8]) -> Result> { + let mut buf = vec![0u8; pt.len().saturating_add(16)]; + let n = self.transport.write_message(pt, &mut buf)?; + buf.truncate(n); + Ok(buf) + } + + pub fn decrypt(&mut self, ct: &[u8]) -> Result> { + let mut buf = vec![0u8; ct.len()]; + let n = self.transport.read_message(ct, &mut buf)?; + buf.truncate(n); + Ok(buf) + } +} + +pub fn handshake(init: &Keys, resp: &Keys) -> Result<(Session, Session)> { + handshake_pinned(init, resp, &resp.identity_pk) +} + +pub fn handshake_pinned(init: &Keys, resp: &Keys, pinned: &[u8; 32]) -> Result<(Session, Session)> { + let params: snow::params::NoiseParams = PATTERN + .parse() + .map_err(|e: snow::Error| Error::new(e.to_string()))?; + let mut initiator = Builder::new(params.clone()) + .local_private_key(&init.prekey_sk)? + .remote_public_key(&resp.prekey_pk)? + .prologue(PROLOGUE)? + .build_initiator()?; + let mut responder = Builder::new(params) + .local_private_key(&resp.prekey_sk)? + .prologue(PROLOGUE)? + .build_responder()?; + + let mut buf = vec![0u8; MSG_BUF]; + let n = initiator.write_message(&[], &mut buf)?; + let msg1 = buf[..n].to_vec(); + let n = responder.read_message(&msg1, &mut buf)?; + if n != 0 { + return Err(Error::new("unexpected handshake payload")); + } + let n = responder.write_message(&[], &mut buf)?; + let msg2 = buf[..n].to_vec(); + let n = initiator.read_message(&msg2, &mut buf)?; + if n != 0 { + return Err(Error::new("unexpected handshake payload")); + } + + let hash = initiator.get_handshake_hash().to_vec(); + if hash != responder.get_handshake_hash() { + return Err(Error::new("handshake hash mismatch")); + } + + let mut i_t = initiator.into_transport_mode()?; + let mut r_t = responder.into_transport_mode()?; + + let i_proof = sign_proof(&init.identity_sk, &init.identity_pk, &hash)?; + let n = i_t.write_message(&i_proof, &mut buf)?; + let ct = buf[..n].to_vec(); + let n = r_t.read_message(&ct, &mut buf)?; + verify_proof(&buf[..n], &init.identity_pk, &hash)?; + + let r_proof = sign_proof(&resp.identity_sk, &resp.identity_pk, &hash)?; + let n = r_t.write_message(&r_proof, &mut buf)?; + let ct = buf[..n].to_vec(); + let n = i_t.read_message(&ct, &mut buf)?; + let proof = &buf[..n]; + if proof.len() < 32 || &proof[..32] != pinned.as_slice() { + return Err(Error::mismatch("fingerprint mismatch")); + } + verify_proof(proof, pinned, &hash)?; + + Ok(( + Session { + transport: i_t, + peer_identity: pinned.to_vec(), + }, + Session { + transport: r_t, + peer_identity: init.identity_pk.to_vec(), + }, + )) +} + +/// Dialer: IK using QR-pinned identity + signed prekey, then mutual identity proofs. +pub async fn handshake_initiator( + stream: &mut S, + local: &Keys, + pinned_id: &[u8], + remote_prekey: &[u8], +) -> Result +where + S: futures::io::AsyncRead + futures::io::AsyncWrite + Unpin, +{ + let params: snow::params::NoiseParams = PATTERN + .parse() + .map_err(|e: snow::Error| Error::new(e.to_string()))?; + let mut hs = Builder::new(params) + .local_private_key(&local.prekey_sk)? + .remote_public_key(remote_prekey)? + .prologue(PROLOGUE)? + .build_initiator()?; + + let mut buf = vec![0u8; MSG_BUF]; + let n = hs.write_message(&[], &mut buf)?; + crate::frame::write_frame(stream, &buf[..n]) + .await + .map_err(|e| Error::new(e.to_string()))?; + let msg2 = crate::frame::read_frame(stream) + .await + .map_err(|e| Error::new(e.to_string()))?; + let n = hs.read_message(&msg2, &mut buf)?; + if n != 0 { + return Err(Error::new("unexpected handshake payload")); + } + let hash = hs.get_handshake_hash().to_vec(); + let mut transport = hs.into_transport_mode()?; + + let proof = sign_proof(&local.identity_sk, &local.identity_pk, &hash)?; + let n = transport.write_message(&proof, &mut buf)?; + crate::frame::write_frame(stream, &buf[..n]) + .await + .map_err(|e| Error::new(e.to_string()))?; + + let ct = crate::frame::read_frame(stream) + .await + .map_err(|e| Error::new(e.to_string()))?; + let n = transport.read_message(&ct, &mut buf)?; + let proof = &buf[..n]; + if proof.len() < 32 || &proof[..32] != pinned_id { + return Err(Error::mismatch("fingerprint mismatch")); + } + verify_proof(proof, pinned_id, &hash)?; + Ok(Session { + transport, + peer_identity: pinned_id.to_vec(), + }) +} + +/// Answerer: IK, look up friend by remote static prekey, then identity proofs. +pub async fn handshake_responder( + stream: &mut S, + local: &Keys, + lookup_identity: F, +) -> Result +where + S: futures::io::AsyncRead + futures::io::AsyncWrite + Unpin, + F: FnOnce(&[u8]) -> Option>, +{ + let params: snow::params::NoiseParams = PATTERN + .parse() + .map_err(|e: snow::Error| Error::new(e.to_string()))?; + let mut hs = Builder::new(params) + .local_private_key(&local.prekey_sk)? + .prologue(PROLOGUE)? + .build_responder()?; + + let mut buf = vec![0u8; MSG_BUF]; + let msg1 = crate::frame::read_frame(stream) + .await + .map_err(|e| Error::new(e.to_string()))?; + let n = hs.read_message(&msg1, &mut buf)?; + if n != 0 { + return Err(Error::new("unexpected handshake payload")); + } + let remote_static = hs + .get_remote_static() + .ok_or_else(|| Error::new("missing remote static"))? + .to_vec(); + let expected_id = + lookup_identity(&remote_static).ok_or_else(|| Error::new("unknown prekey"))?; + let n = hs.write_message(&[], &mut buf)?; + crate::frame::write_frame(stream, &buf[..n]) + .await + .map_err(|e| Error::new(e.to_string()))?; + let hash = hs.get_handshake_hash().to_vec(); + let mut transport = hs.into_transport_mode()?; + + let ct = crate::frame::read_frame(stream) + .await + .map_err(|e| Error::new(e.to_string()))?; + let n = transport.read_message(&ct, &mut buf)?; + let proof = &buf[..n]; + if proof.len() < 32 || proof[..32] != expected_id { + return Err(Error::mismatch("fingerprint mismatch")); + } + verify_proof(proof, &expected_id, &hash)?; + + let proof = sign_proof(&local.identity_sk, &local.identity_pk, &hash)?; + let n = transport.write_message(&proof, &mut buf)?; + crate::frame::write_frame(stream, &buf[..n]) + .await + .map_err(|e| Error::new(e.to_string()))?; + Ok(Session { + transport, + peer_identity: expected_id, + }) +} + +fn sign_proof(id_sk: &[u8; 32], id_pk: &[u8; 32], hash: &[u8]) -> Result> { + let sk = SigningKey::from_bytes(id_sk); + let sig = sk.sign(hash); + let mut out = Vec::with_capacity(96); + out.extend_from_slice(id_pk); + out.extend_from_slice(&sig.to_bytes()); + Ok(out) +} + +fn verify_proof(proof: &[u8], expected_pk: &[u8], hash: &[u8]) -> Result<()> { + if proof.len() != 96 { + return Err(Error::new("bad identity proof")); + } + if &proof[..32] != expected_pk { + return Err(Error::mismatch("fingerprint mismatch")); + } + let pk: [u8; 32] = proof[..32].try_into().expect("32"); + let sig: [u8; 64] = proof[32..].try_into().expect("64"); + let vk = VerifyingKey::from_bytes(&pk).map_err(|e| Error::new(e.to_string()))?; + vk.verify(hash, &Signature::from_bytes(&sig)) + .map_err(|_| Error::mismatch("fingerprint mismatch"))?; + Ok(()) +} + +fn to_32(v: &[u8]) -> Result<[u8; 32]> { + v.try_into().map_err(|_| Error::new("need 32 bytes")) +} diff --git a/src/store.rs b/src/store.rs index ddb5e9f..ebdc7fe 100644 --- a/src/store.rs +++ b/src/store.rs @@ -5,7 +5,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; use rand::rngs::OsRng; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, params}; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; @@ -40,6 +41,8 @@ pub struct SelfIdentity { pub identity_sk: Vec, pub identity_pk: Vec, pub onion: String, + pub prekey_sk: Vec, + pub prekey_pk: Vec, } pub struct Friend { @@ -48,6 +51,13 @@ pub struct Friend { pub petname: Option, pub onion: String, pub last_connect_ok: bool, + pub prekey: Vec, +} + +#[derive(Debug)] +pub struct Message { + pub dir: String, + pub plaintext: Vec, } impl Store { @@ -55,7 +65,7 @@ impl Store { Self::open_at(&home_dir()?) } - fn open_at(home: &Path) -> Result { + pub fn open_at(home: &Path) -> Result { mkdir_700(home)?; mkdir_700(&home.join("arti"))?; let db_path = home.join("onionwire.db"); @@ -71,7 +81,8 @@ impl Store { onion TEXT NOT NULL, onion_updated_at INTEGER NOT NULL, added_at INTEGER NOT NULL, - last_connect_ok INTEGER NOT NULL DEFAULT 1 + last_connect_ok INTEGER NOT NULL DEFAULT 1, + prekey BLOB NOT NULL DEFAULT x'' ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, @@ -85,7 +96,9 @@ impl Store { identity_sk BLOB NOT NULL, identity_pk BLOB NOT NULL, onion TEXT NOT NULL, - onion_rotated_at INTEGER NOT NULL + onion_rotated_at INTEGER NOT NULL, + prekey_sk BLOB NOT NULL, + prekey_pk BLOB NOT NULL ); ", )?; @@ -96,16 +109,35 @@ impl Store { } fn migrate(&self) -> Result<()> { - let has: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('friends') WHERE name = 'last_connect_ok'", - [], - |row| row.get(0), + self.add_column_if_missing( + "friends", + "last_connect_ok", + "ALTER TABLE friends ADD COLUMN last_connect_ok INTEGER NOT NULL DEFAULT 1", )?; + self.add_column_if_missing( + "friends", + "prekey", + "ALTER TABLE friends ADD COLUMN prekey BLOB NOT NULL DEFAULT x''", + )?; + self.add_column_if_missing( + "self", + "prekey_sk", + "ALTER TABLE self ADD COLUMN prekey_sk BLOB NOT NULL DEFAULT x''", + )?; + self.add_column_if_missing( + "self", + "prekey_pk", + "ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''", + )?; + Ok(()) + } + + fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> { + let sql = + format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'"); + let has: i64 = self.conn.query_row(&sql, [], |row| row.get(0))?; if has == 0 { - self.conn.execute( - "ALTER TABLE friends ADD COLUMN last_connect_ok INTEGER NOT NULL DEFAULT 1", - [], - )?; + self.conn.execute(ddl, [])?; } Ok(()) } @@ -120,11 +152,25 @@ impl Store { let signing = SigningKey::generate(&mut OsRng); let sk = signing.to_bytes().to_vec(); let pk = signing.verifying_key().to_bytes().to_vec(); + let (psk, ppk) = gen_prekey(); self.conn.execute( - "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at) - VALUES (1, ?1, ?2, '', 0)", - params![sk, pk], + "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk) + VALUES (1, ?1, ?2, '', 0, ?3, ?4)", + params![sk, pk, psk, ppk], )?; + } else { + let empty: i64 = self.conn.query_row( + "SELECT CASE WHEN length(prekey_sk) = 32 AND length(prekey_pk) = 32 THEN 0 ELSE 1 END FROM self WHERE id = 1", + [], + |row| row.get(0), + )?; + if empty != 0 { + let (psk, ppk) = gen_prekey(); + self.conn.execute( + "UPDATE self SET prekey_sk = ?1, prekey_pk = ?2 WHERE id = 1", + params![psk, ppk], + )?; + } } Ok(()) } @@ -132,13 +178,15 @@ impl Store { pub fn self_identity(&self) -> Result { self.conn .query_row( - "SELECT identity_sk, identity_pk, onion FROM self WHERE id = 1", + "SELECT identity_sk, identity_pk, onion, prekey_sk, prekey_pk FROM self WHERE id = 1", [], |row| { Ok(SelfIdentity { identity_sk: row.get(0)?, identity_pk: row.get(1)?, onion: row.get(2)?, + prekey_sk: row.get(3)?, + prekey_pk: row.get(4)?, }) }, ) @@ -169,17 +217,9 @@ impl Store { pub fn get_friend(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( - "SELECT pubkey, fingerprint, petname, onion, last_connect_ok FROM friends WHERE pubkey = ?1", + "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE pubkey = ?1", params![pubkey], - |row| { - Ok(Friend { - pubkey: row.get(0)?, - fingerprint: row.get(1)?, - petname: row.get(2)?, - onion: row.get(3)?, - last_connect_ok: row.get::<_, i64>(4)? != 0, - }) - }, + friend_from_row, ) .optional() .map_err(Into::into) @@ -187,16 +227,78 @@ impl Store { pub fn list_friends(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT pubkey, fingerprint, petname, onion, last_connect_ok FROM friends + "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends ORDER BY COALESCE(petname, fingerprint) COLLATE NOCASE", )?; - let rows = stmt.query_map([], |row| { - Ok(Friend { - pubkey: row.get(0)?, - fingerprint: row.get(1)?, - petname: row.get(2)?, - onion: row.get(3)?, - last_connect_ok: row.get::<_, i64>(4)? != 0, + let rows = stmt.query_map([], friend_from_row)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } + + pub fn set_onion(&self, onion: &str) -> Result<()> { + let now = unix_now(); + self.conn.execute( + "UPDATE self SET onion = ?1, onion_rotated_at = ?2 WHERE id = 1", + params![onion, now], + )?; + Ok(()) + } + + pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> { + let n = self.conn.execute( + "UPDATE friends SET prekey = ?1 WHERE pubkey = ?2", + params![prekey, pubkey], + )?; + if n == 0 { + return Err(Error("friend not found".into())); + } + Ok(()) + } + + pub fn get_friend_by_prekey(&self, prekey: &[u8]) -> Result> { + self.conn + .query_row( + "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE prekey = ?1", + params![prekey], + friend_from_row, + ) + .optional() + .map_err(Into::into) + } + + pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> { + if dir != "in" && dir != "out" { + return Err(Error("dir must be in or out".into())); + } + let friend_id: i64 = self + .conn + .query_row( + "SELECT id FROM friends WHERE pubkey = ?1", + params![friend_pk], + |row| row.get(0), + ) + .map_err(|_| Error("friend not found".into()))?; + self.conn.execute( + "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", + params![friend_id, dir, plaintext, unix_now()], + )?; + Ok(()) + } + + pub fn list_messages(&self, friend_pk: &[u8]) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT m.dir, m.plaintext FROM messages m + JOIN friends f ON f.id = m.friend_id + WHERE f.pubkey = ?1 + ORDER BY m.id", + )?; + let rows = stmt.query_map(params![friend_pk], |row| { + Ok(Message { + dir: row.get(0)?, + plaintext: row.get(1)?, }) })?; let mut out = Vec::new(); @@ -207,6 +309,23 @@ impl Store { } } +fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Friend { + pubkey: row.get(0)?, + fingerprint: row.get(1)?, + petname: row.get(2)?, + onion: row.get(3)?, + last_connect_ok: row.get::<_, i64>(4)? != 0, + prekey: row.get(5)?, + }) +} + +fn gen_prekey() -> (Vec, Vec) { + let sk = StaticSecret::random_from_rng(OsRng); + let pk = X25519Public::from(&sk); + (sk.to_bytes().to_vec(), pk.to_bytes().to_vec()) +} + fn home_dir() -> Result { if let Some(p) = std::env::var_os("ONIONWIRE_HOME") { return Ok(PathBuf::from(p)); diff --git a/src/tui.rs b/src/tui.rs index 2c1bf10..6edaccb 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,14 +1,18 @@ use std::io; -use rand::RngCore; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::{DefaultTerminal, Frame}; use crate::qr::{self, QrPayload}; use crate::store::{Friend, Store}; +pub fn fingerprint_mismatch_banner() -> &'static str { + "fingerprint mismatch" +} + pub fn run(store: Store) -> Result<(), String> { let mut app = App::new(store)?; let mut terminal = ratatui::init(); @@ -25,6 +29,8 @@ struct App { me_fp: String, me_onion: String, identity_sk: Vec, + prekey_pk: Vec, + alert: Option, } enum Screen { @@ -47,6 +53,8 @@ impl App { me_fp: to_hex(&me.identity_pk), me_onion: me.onion, identity_sk: me.identity_sk, + prekey_pk: me.prekey_pk, + alert: None, }) } @@ -126,14 +134,13 @@ impl App { } fn open_share(&mut self) -> Result<(), String> { - let mut spk = [0u8; 32]; - rand::rngs::OsRng.fill_bytes(&mut spk); let onion = if self.me_onion.is_empty() { "unset" } else { self.me_onion.as_str() }; - let payload = qr::encode(&self.identity_sk, onion, &spk).map_err(|e| e.to_string())?; + let payload = + qr::encode(&self.identity_sk, onion, &self.prekey_pk).map_err(|e| e.to_string())?; let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?; self.screen = Screen::Share { art, payload }; Ok(()) @@ -167,6 +174,9 @@ impl App { self.store .upsert_friend(&payload.pubkey, &payload.onion, None) .map_err(|e| e.to_string())?; + self.store + .set_friend_prekey(&payload.pubkey, &payload.signed_prekey) + .map_err(|e| e.to_string())?; self.reload_friends()?; self.screen = Screen::Main; Ok(()) @@ -195,8 +205,19 @@ impl App { fn draw_main(&self, f: &mut Frame) { let area = f.area(); let cols = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + let body = if self.alert.is_some() { + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0]) + } else { + Layout::vertical([Constraint::Length(0), Constraint::Min(1)]).split(cols[0]) + }; + if let Some(msg) = &self.alert { + f.render_widget( + Paragraph::new(msg.as_str()).style(Style::default().fg(Color::Red)), + body[0], + ); + } let panes = - Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(cols[0]); + Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(body[1]); let items: Vec = self .friends diff --git a/tests/protocol.rs b/tests/protocol.rs new file mode 100644 index 0000000..2bb99fd --- /dev/null +++ b/tests/protocol.rs @@ -0,0 +1,38 @@ +//! M3 protocol: framed Noise IK, identity pin, ciphertext on the wire. + +use onionwire::session::{self, Keys}; +use onionwire::{frame, tui}; + +#[test] +fn frame_roundtrip() { + let f = frame::encode(b"hi").expect("encode"); + assert_eq!(f[0], 1, "version byte"); + assert_eq!(&f[1..5], &(2u32).to_be_bytes()); + assert_eq!(frame::decode(&f).expect("decode"), b"hi"); +} + +#[test] +fn noise_ik_roundtrip_hides_plaintext() { + let a = Keys::generate(); + let b = Keys::generate(); + let (mut sa, mut sb) = session::handshake(&a, &b).expect("handshake"); + let ct = sa.encrypt(b"hello wire").expect("enc"); + assert!( + !ct.windows(10).any(|w| w == b"hello wire"), + "plaintext must not appear in ciphertext" + ); + assert_eq!(sb.decrypt(&ct).expect("dec"), b"hello wire"); +} + +#[test] +fn fingerprint_mismatch_is_hard_fail() { + let a = Keys::generate(); + let b = Keys::generate(); + let evil = Keys::generate(); + let err = match session::handshake_pinned(&a, &b, &evil.identity_pk) { + Err(e) => e, + Ok(_) => panic!("expected fingerprint mismatch"), + }; + assert!(err.is_fingerprint_mismatch(), "{err}"); + assert_eq!(tui::fingerprint_mismatch_banner(), "fingerprint mismatch"); +} diff --git a/tests/store.rs b/tests/store.rs index 5432e18..2ec8164 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -116,3 +116,50 @@ fn unknown_pubkey_inserts_new_row() { assert_eq!(store.get_friend(&pk(1)).unwrap().unwrap().onion, "a.onion"); assert_eq!(store.get_friend(&pk(2)).unwrap().unwrap().onion, "b.onion"); } + +#[test] +fn open_at_two_homes_are_independent() { + let a = tempfile::tempdir().expect("a"); + let b = tempfile::tempdir().expect("b"); + let sa = onionwire::Store::open_at(a.path()).expect("open a"); + let sb = onionwire::Store::open_at(b.path()).expect("open b"); + let ia = sa.self_identity().unwrap(); + let ib = sb.self_identity().unwrap(); + assert_ne!(ia.identity_pk, ib.identity_pk); +} + +#[test] +fn incoming_message_persists_plaintext() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + store.upsert_friend(&pk(1), "b.onion", None).unwrap(); + store.append_message(&pk(1), "in", b"hello wire").unwrap(); + let msgs = store.list_messages(&pk(1)).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].dir, "in"); + assert_eq!(msgs[0].plaintext, b"hello wire"); +} + +#[test] +fn self_has_x25519_prekey_and_set_onion() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().unwrap(); + assert_eq!(me.prekey_pk.len(), 32); + assert_eq!(me.prekey_sk.len(), 32); + assert_ne!(me.prekey_pk, me.prekey_sk); + store.set_onion("abc.onion").unwrap(); + assert_eq!(store.self_identity().unwrap().onion, "abc.onion"); +} + +#[test] +fn friend_prekey_lookup() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let spk = [9u8; 32]; + store.upsert_friend(&pk(1), "b.onion", None).unwrap(); + store.set_friend_prekey(&pk(1), &spk).unwrap(); + let f = store.get_friend_by_prekey(&spk).unwrap().expect("found"); + assert_eq!(f.pubkey, pk(1)); + assert_eq!(f.prekey, spk); +} diff --git a/tests/two_node.rs b/tests/two_node.rs new file mode 100644 index 0000000..70c496b --- /dev/null +++ b/tests/two_node.rs @@ -0,0 +1,75 @@ +//! M3: two live Arti nodes, encrypted send, plaintext only in sqlite. + +use std::time::{Duration, Instant}; + +use onionwire::node::{self, Node}; + +const HELLO: &[u8] = b"hello wire"; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs live Tor network"] +async fn alice_sends_hello_wire_bob_sqlite_has_plaintext() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with_test_writer() + .try_init(); + + let root = tempfile::tempdir().expect("tempdir"); + let alice_home = root.path().join("alice"); + let bob_home = root.path().join("bob"); + + eprintln!("starting alice + bob nodes…"); + let (alice, bob) = tokio::join!( + Node::start(alice_home.clone()), + Node::start(bob_home.clone()) + ); + let alice = alice.expect("alice node"); + let bob = bob.expect("bob node"); + eprintln!("alice onion={}", alice.onion()); + eprintln!("bob onion={}", bob.onion()); + assert_ne!(alice.onion(), bob.onion()); + + let a_qr = alice.qr_payload().expect("alice qr"); + let b_qr = bob.qr_payload().expect("bob qr"); + alice.add_friend_from_qr(&b_qr).expect("alice adds bob"); + bob.add_friend_from_qr(&a_qr).expect("bob adds alice"); + + eprintln!("alice → bob hello wire"); + alice + .send(&bob.identity_pk(), HELLO) + .await + .expect("alice send"); + + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let msgs = bob + .list_messages(&alice.identity_pk()) + .expect("bob messages"); + if msgs.iter().any(|m| m.dir == "in" && m.plaintext == HELLO) { + break; + } + if Instant::now() >= deadline { + panic!("bob sqlite missing incoming hello wire; got {msgs:?}"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + assert!( + !node::dir_contains_bytes(&alice.arti_dir(), HELLO), + "plaintext leaked into alice arti dir" + ); + assert!( + !node::dir_contains_bytes(&bob.arti_dir(), HELLO), + "plaintext leaked into bob arti dir" + ); + assert!( + !node::dir_contains_bytes(&alice.cache_dir(), HELLO), + "plaintext leaked into alice cache dir" + ); + assert!( + !node::dir_contains_bytes(&bob.cache_dir(), HELLO), + "plaintext leaked into bob cache dir" + ); +}