Add Noise IK encrypted stream over Arti.

M3: framed Noise_IK_25519_ChaChaPoly_BLAKE2s on the onion pipe, QR-pinned
identity proofs, plaintext only in sqlite. Live two_node send of hello wire.
This commit is contained in:
Sirius DevOps 2026-09-10 02:07:40 -04:00
parent 4fb4648552
commit a2dfe3b2b8
No known key found for this signature in database
12 changed files with 1208 additions and 47 deletions

189
Cargo.lock generated
View file

@ -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"

View file

@ -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"

72
src/frame.rs Normal file
View file

@ -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<T> = std::result::Result<T, Error>;
#[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<Vec<u8>> {
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<Vec<u8>> {
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: AsyncWrite + Unpin>(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: AsyncRead + Unpin>(r: &mut R) -> Result<Vec<u8>> {
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)
}

78
src/hs.rs Normal file
View file

@ -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<TorClient<PreferredRuntime>>;
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<Client, String> {
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<OnionServiceConfig, String> {
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<String, String> {
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)) => {}
}
}
}

View file

@ -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};

215
src/node.rs Normal file
View file

@ -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<Store>,
client: Client,
_svc: Arc<RunningOnionService>,
onion: String,
keys: Keys,
}
impl Node {
pub async fn start(home: PathBuf) -> Result<Arc<Self>, 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<String, String> {
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<Vec<Message>, 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::<String>;
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<S>(&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
}

315
src/session.rs Normal file
View file

@ -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<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error {
mismatch: bool,
msg: String,
}
impl Error {
fn new(msg: impl Into<String>) -> Self {
Self {
mismatch: false,
msg: msg.into(),
}
}
fn mismatch(msg: impl Into<String>) -> 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<snow::Error> 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<Self> {
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<u8>,
}
impl Session {
pub fn encrypt(&mut self, pt: &[u8]) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<S>(
stream: &mut S,
local: &Keys,
pinned_id: &[u8],
remote_prekey: &[u8],
) -> Result<Session>
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<S, F>(
stream: &mut S,
local: &Keys,
lookup_identity: F,
) -> Result<Session>
where
S: futures::io::AsyncRead + futures::io::AsyncWrite + Unpin,
F: FnOnce(&[u8]) -> Option<Vec<u8>>,
{
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<Vec<u8>> {
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"))
}

View file

@ -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<T> = std::result::Result<T, Error>;
@ -40,6 +41,8 @@ pub struct SelfIdentity {
pub identity_sk: Vec<u8>,
pub identity_pk: Vec<u8>,
pub onion: String,
pub prekey_sk: Vec<u8>,
pub prekey_pk: Vec<u8>,
}
pub struct Friend {
@ -48,6 +51,13 @@ pub struct Friend {
pub petname: Option<String>,
pub onion: String,
pub last_connect_ok: bool,
pub prekey: Vec<u8>,
}
#[derive(Debug)]
pub struct Message {
pub dir: String,
pub plaintext: Vec<u8>,
}
impl Store {
@ -55,7 +65,7 @@ impl Store {
Self::open_at(&home_dir()?)
}
fn open_at(home: &Path) -> Result<Self> {
pub fn open_at(home: &Path) -> Result<Self> {
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),
)?;
if has == 0 {
self.conn.execute(
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(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<SelfIdentity> {
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<Option<Friend>> {
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<Vec<Friend>> {
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<Option<Friend>> {
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<Vec<Message>> {
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<Friend> {
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<u8>, Vec<u8>) {
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<PathBuf> {
if let Some(p) = std::env::var_os("ONIONWIRE_HOME") {
return Ok(PathBuf::from(p));

View file

@ -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<u8>,
prekey_pk: Vec<u8>,
alert: Option<String>,
}
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<ListItem> = self
.friends

38
tests/protocol.rs Normal file
View file

@ -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");
}

View file

@ -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);
}

75
tests/two_node.rs Normal file
View file

@ -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"
);
}