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:
parent
4fb4648552
commit
a2dfe3b2b8
12 changed files with 1208 additions and 47 deletions
189
Cargo.lock
generated
189
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
72
src/frame.rs
Normal 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
78
src/hs.rs
Normal 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)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
215
src/node.rs
Normal 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
315
src/session.rs
Normal 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"))
|
||||
}
|
||||
187
src/store.rs
187
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<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));
|
||||
|
|
|
|||
31
src/tui.rs
31
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<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
38
tests/protocol.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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
75
tests/two_node.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue