[verified] Add F4 onion locator rotate with signed loc frames.
Identity key stays put. Typing ROTATE publishes a new Arti HS, hard-cuts the old nickname, persists self.onion, and pushes loc to reachable friends.
This commit is contained in:
parent
a2dfe3b2b8
commit
07aa97711c
8 changed files with 743 additions and 87 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod hs;
|
pub mod hs;
|
||||||
|
pub mod loc;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
|
||||||
102
src/loc.rs
Normal file
102
src/loc.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(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 {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Loc {
|
||||||
|
pub onion: String,
|
||||||
|
pub ts: i64,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX: &[u8] = b"loc ";
|
||||||
|
|
||||||
|
pub fn sign(identity_sk: &[u8], onion: &str, ts: i64) -> Result<Loc> {
|
||||||
|
let sk_bytes: [u8; 32] = identity_sk
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
|
||||||
|
let sk = SigningKey::from_bytes(&sk_bytes);
|
||||||
|
let sig = sk.sign(&sign_msg(onion, ts)).to_bytes().to_vec();
|
||||||
|
Ok(Loc {
|
||||||
|
onion: onion.to_string(),
|
||||||
|
ts,
|
||||||
|
sig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(identity_pk: &[u8], loc: &Loc) -> bool {
|
||||||
|
if identity_pk.len() != 32 || loc.sig.len() != 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pk: [u8; 32] = match identity_pk.try_into() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let sig: [u8; 64] = match loc.sig.as_slice().try_into() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
vk.verify(&sign_msg(&loc.onion, loc.ts), &Signature::from_bytes(&sig))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(loc: &Loc) -> Vec<u8> {
|
||||||
|
let mut out = Vec::from(PREFIX);
|
||||||
|
out.extend_from_slice(loc.onion.as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(loc.ts.to_string().as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(to_hex(&loc.sig).as_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(pt: &[u8]) -> Option<Loc> {
|
||||||
|
let rest = pt.strip_prefix(PREFIX)?;
|
||||||
|
let text = std::str::from_utf8(rest).ok()?;
|
||||||
|
let mut parts = text.splitn(3, '\n');
|
||||||
|
let onion = parts.next()?.to_string();
|
||||||
|
let ts = parts.next()?.parse::<i64>().ok()?;
|
||||||
|
let sig = from_hex(parts.next()?)?;
|
||||||
|
if onion.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Loc { onion, ts, sig })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_msg(onion: &str, ts: i64) -> Vec<u8> {
|
||||||
|
let mut msg = Vec::from(onion.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(ts.to_string().as_bytes());
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_hex(s: &str) -> Option<Vec<u8>> {
|
||||||
|
if s.is_empty() || !s.len().is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
27
src/main.rs
27
src/main.rs
|
|
@ -1,14 +1,17 @@
|
||||||
fn main() {
|
#[tokio::main]
|
||||||
match onionwire::Store::open() {
|
async fn main() {
|
||||||
Ok(store) => {
|
if let Err(e) = boot().await {
|
||||||
if let Err(e) = onionwire::tui::run(store) {
|
eprintln!("onionwire: {e}");
|
||||||
eprintln!("onionwire: {e}");
|
std::process::exit(1);
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("onionwire: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn boot() -> Result<(), String> {
|
||||||
|
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
|
||||||
|
eprintln!("onionwire: bootstrapping Arti…");
|
||||||
|
let node = onionwire::node::Node::start(home).await?;
|
||||||
|
let handle = tokio::runtime::Handle::current();
|
||||||
|
tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
|
||||||
222
src/node.rs
222
src/node.rs
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use futures::io::{AsyncRead, AsyncWrite};
|
use futures::io::{AsyncRead, AsyncWrite};
|
||||||
|
|
@ -11,16 +11,33 @@ use tor_hsservice::{RunningOnionService, handle_rend_requests};
|
||||||
|
|
||||||
use crate::frame;
|
use crate::frame;
|
||||||
use crate::hs::{self, Client, HS_PORT};
|
use crate::hs::{self, Client, HS_PORT};
|
||||||
|
use crate::loc;
|
||||||
use crate::qr;
|
use crate::qr;
|
||||||
use crate::session::{self, Keys};
|
use crate::session::{self, Keys};
|
||||||
use crate::store::{Message, Store};
|
use crate::store::{Friend, Message, Store};
|
||||||
|
|
||||||
|
pub struct RotateResult {
|
||||||
|
pub notified: usize,
|
||||||
|
pub friends: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HsHandle {
|
||||||
|
_svc: Arc<RunningOnionService>,
|
||||||
|
rend: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HsHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.rend.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Node {
|
pub struct Node {
|
||||||
home: PathBuf,
|
home: PathBuf,
|
||||||
store: Mutex<Store>,
|
store: Mutex<Store>,
|
||||||
client: Client,
|
client: Client,
|
||||||
_svc: Arc<RunningOnionService>,
|
hs: Mutex<Option<HsHandle>>,
|
||||||
onion: String,
|
onion: Mutex<String>,
|
||||||
keys: Keys,
|
keys: Keys,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,11 +46,12 @@ impl Node {
|
||||||
let store = Store::open_at(&home).map_err(|e| e.to_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 me = store.self_identity().map_err(|e| e.to_string())?;
|
||||||
let keys = Keys::from_self(&me).map_err(|e| e.to_string())?;
|
let keys = Keys::from_self(&me).map_err(|e| e.to_string())?;
|
||||||
|
let nickname = store.hs_nickname().map_err(|e| e.to_string())?;
|
||||||
let state = home.join("arti");
|
let state = home.join("arti");
|
||||||
let cache = home.join("cache");
|
let cache = home.join("cache");
|
||||||
let client = hs::bootstrapped(&state, &cache).await?;
|
let client = hs::bootstrapped(&state, &cache).await?;
|
||||||
let launched = client
|
let launched = client
|
||||||
.launch_onion_service(hs::hs_config("onionwire")?)
|
.launch_onion_service(hs::hs_config(&nickname)?)
|
||||||
.map_err(|e| format!("launch_onion_service: {e}"))?
|
.map_err(|e| format!("launch_onion_service: {e}"))?
|
||||||
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
|
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
|
||||||
let (svc, rend) = launched;
|
let (svc, rend) = launched;
|
||||||
|
|
@ -44,30 +62,17 @@ impl Node {
|
||||||
home,
|
home,
|
||||||
store: Mutex::new(store),
|
store: Mutex::new(store),
|
||||||
client,
|
client,
|
||||||
_svc: svc,
|
hs: Mutex::new(None),
|
||||||
onion,
|
onion: Mutex::new(onion),
|
||||||
keys,
|
keys,
|
||||||
});
|
});
|
||||||
let serve = Arc::clone(&node);
|
let rend = spawn_rend(Arc::clone(&node), rend);
|
||||||
tokio::spawn(async move {
|
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend });
|
||||||
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)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn onion(&self) -> &str {
|
pub fn onion(&self) -> String {
|
||||||
&self.onion
|
self.onion.lock().map(|g| g.clone()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identity_pk(&self) -> [u8; 32] {
|
pub fn identity_pk(&self) -> [u8; 32] {
|
||||||
|
|
@ -83,12 +88,16 @@ impl Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn qr_payload(&self) -> Result<String, String> {
|
pub fn qr_payload(&self) -> Result<String, String> {
|
||||||
qr::encode(&self.keys.identity_sk, &self.onion, &self.keys.prekey_pk)
|
qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> {
|
pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> {
|
||||||
let p = qr::decode(raw).map_err(|e| e.to_string())?;
|
let p = qr::decode(raw).map_err(|e| e.to_string())?;
|
||||||
|
self.add_friend_payload(&p)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_friend_payload(&self, p: &qr::QrPayload) -> Result<(), String> {
|
||||||
let store = self.store.lock().map_err(|e| e.to_string())?;
|
let store = self.store.lock().map_err(|e| e.to_string())?;
|
||||||
store
|
store
|
||||||
.upsert_friend(&p.pubkey, &p.onion, None)
|
.upsert_friend(&p.pubkey, &p.onion, None)
|
||||||
|
|
@ -99,6 +108,31 @@ impl Node {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_friend(&self, pubkey: &[u8]) -> Result<Option<Friend>, String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.get_friend(pubkey)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_friends(&self) -> Result<Vec<Friend>, String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.list_friends()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.get_friend(pubkey)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "friend not found".into())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>, String> {
|
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>, String> {
|
||||||
self.store
|
self.store
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -107,6 +141,72 @@ impl Node {
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
self.client.connect((onion, HS_PORT)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("connect {onion}:{HS_PORT} timed out"))?
|
||||||
|
.map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rotate(self: &Arc<Self>) -> Result<RotateResult, String> {
|
||||||
|
let old_nick = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.hs_nickname()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let new_nick = next_hs_nickname(&old_nick);
|
||||||
|
let launched = self
|
||||||
|
.client
|
||||||
|
.launch_onion_service(hs::hs_config(&new_nick)?)
|
||||||
|
.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)?;
|
||||||
|
// ponytail: hard-cut old HS before waiting; keeping both stalled ow1 at Bootstrapping.
|
||||||
|
*self.hs.lock().map_err(|e| e.to_string())? = None;
|
||||||
|
hs::wait_until_published(&svc, &onion).await?;
|
||||||
|
let rend = spawn_rend(Arc::clone(self), rend);
|
||||||
|
*self.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend });
|
||||||
|
{
|
||||||
|
let store = self.store.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.set_onion(&onion).map_err(|e| e.to_string())?;
|
||||||
|
store
|
||||||
|
.set_hs_nickname(&new_nick)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
*self.onion.lock().map_err(|e| e.to_string())? = onion.clone();
|
||||||
|
|
||||||
|
let ts = unix_now();
|
||||||
|
let loc = loc::sign(&self.keys.identity_sk, &onion, ts).map_err(|e| e.to_string())?;
|
||||||
|
let loc_pt = loc::encode(&loc);
|
||||||
|
let friends = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.list_friends()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut notified = 0;
|
||||||
|
for f in &friends {
|
||||||
|
if self
|
||||||
|
.push_loc(&f.pubkey, &f.onion, &f.prekey, &loc_pt)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
notified += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RotateResult {
|
||||||
|
notified,
|
||||||
|
friends: friends.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
|
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
|
||||||
let (onion, prekey) = {
|
let (onion, prekey) = {
|
||||||
let store = self.store.lock().map_err(|e| e.to_string())?;
|
let store = self.store.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
@ -139,6 +239,29 @@ impl Node {
|
||||||
Err(last.unwrap_or_else(|| "send timed out".into()))
|
Err(last.unwrap_or_else(|| "send timed out".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn push_loc(
|
||||||
|
&self,
|
||||||
|
friend_pk: &[u8],
|
||||||
|
onion: &str,
|
||||||
|
prekey: &[u8],
|
||||||
|
loc_pt: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if prekey.len() != 32 {
|
||||||
|
return Err("friend missing prekey".into());
|
||||||
|
}
|
||||||
|
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, loc_pt).await {
|
||||||
|
Ok(()) => 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(|| "loc push timed out".into()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn try_send(
|
async fn try_send(
|
||||||
&self,
|
&self,
|
||||||
onion: &str,
|
onion: &str,
|
||||||
|
|
@ -177,6 +300,20 @@ impl Node {
|
||||||
.map_err(session_err)?;
|
.map_err(session_err)?;
|
||||||
let ct = frame::read_frame(stream).await.map_err(|e| e.to_string())?;
|
let ct = frame::read_frame(stream).await.map_err(|e| e.to_string())?;
|
||||||
let pt = sess.decrypt(&ct).map_err(session_err)?;
|
let pt = sess.decrypt(&ct).map_err(session_err)?;
|
||||||
|
if let Some(loc) = loc::decode(&pt) {
|
||||||
|
let applied = store.lock().map_err(|e| e.to_string())?.apply_loc(
|
||||||
|
&sess.peer_identity,
|
||||||
|
&loc.onion,
|
||||||
|
loc.ts,
|
||||||
|
&loc.sig,
|
||||||
|
);
|
||||||
|
match applied {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"),
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
store
|
store
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
@ -186,6 +323,41 @@ impl Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_rend(
|
||||||
|
node: Arc<Node>,
|
||||||
|
rend: impl futures::Stream<Item = tor_hsservice::RendRequest> + Send + 'static,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
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(&node);
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_hs_nickname(cur: &str) -> String {
|
||||||
|
let n = cur
|
||||||
|
.strip_prefix("ow")
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("ow{}", n.saturating_add(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
fn session_err(e: session::Error) -> String {
|
fn session_err(e: session::Error) -> String {
|
||||||
if e.is_fingerprint_mismatch() {
|
if e.is_fingerprint_mismatch() {
|
||||||
crate::tui::fingerprint_mismatch_banner().to_string()
|
crate::tui::fingerprint_mismatch_banner().to_string()
|
||||||
|
|
|
||||||
63
src/store.rs
63
src/store.rs
|
|
@ -65,6 +65,10 @@ impl Store {
|
||||||
Self::open_at(&home_dir()?)
|
Self::open_at(&home_dir()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn home_dir() -> Result<PathBuf> {
|
||||||
|
home_dir()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open_at(home: &Path) -> Result<Self> {
|
pub fn open_at(home: &Path) -> Result<Self> {
|
||||||
mkdir_700(home)?;
|
mkdir_700(home)?;
|
||||||
mkdir_700(&home.join("arti"))?;
|
mkdir_700(&home.join("arti"))?;
|
||||||
|
|
@ -98,7 +102,8 @@ impl Store {
|
||||||
onion TEXT NOT NULL,
|
onion TEXT NOT NULL,
|
||||||
onion_rotated_at INTEGER NOT NULL,
|
onion_rotated_at INTEGER NOT NULL,
|
||||||
prekey_sk BLOB NOT NULL,
|
prekey_sk BLOB NOT NULL,
|
||||||
prekey_pk BLOB NOT NULL
|
prekey_pk BLOB NOT NULL,
|
||||||
|
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
|
||||||
);
|
);
|
||||||
",
|
",
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -129,6 +134,11 @@ impl Store {
|
||||||
"prekey_pk",
|
"prekey_pk",
|
||||||
"ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''",
|
"ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''",
|
||||||
)?;
|
)?;
|
||||||
|
self.add_column_if_missing(
|
||||||
|
"self",
|
||||||
|
"hs_nickname",
|
||||||
|
"ALTER TABLE self ADD COLUMN hs_nickname TEXT NOT NULL DEFAULT 'ow0'",
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,8 +164,8 @@ impl Store {
|
||||||
let pk = signing.verifying_key().to_bytes().to_vec();
|
let pk = signing.verifying_key().to_bytes().to_vec();
|
||||||
let (psk, ppk) = gen_prekey();
|
let (psk, ppk) = gen_prekey();
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk)
|
"INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk, hs_nickname)
|
||||||
VALUES (1, ?1, ?2, '', 0, ?3, ?4)",
|
VALUES (1, ?1, ?2, '', 0, ?3, ?4, 'ow0')",
|
||||||
params![sk, pk, psk, ppk],
|
params![sk, pk, psk, ppk],
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -238,6 +248,22 @@ impl Store {
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hs_nickname(&self) -> Result<String> {
|
||||||
|
self.conn
|
||||||
|
.query_row("SELECT hs_nickname FROM self WHERE id = 1", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_hs_nickname(&self, nickname: &str) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE self SET hs_nickname = ?1 WHERE id = 1",
|
||||||
|
params![nickname],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_onion(&self, onion: &str) -> Result<()> {
|
pub fn set_onion(&self, onion: &str) -> Result<()> {
|
||||||
let now = unix_now();
|
let now = unix_now();
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
|
|
@ -247,6 +273,37 @@ impl Store {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a signed locator update. Bad sig / stale ts / unknown friend → no change.
|
||||||
|
pub fn apply_loc(&self, pubkey: &[u8], onion: &str, ts: i64, sig: &[u8]) -> Result<bool> {
|
||||||
|
let loc = crate::loc::Loc {
|
||||||
|
onion: onion.to_string(),
|
||||||
|
ts,
|
||||||
|
sig: sig.to_vec(),
|
||||||
|
};
|
||||||
|
if !crate::loc::verify(pubkey, &loc) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let Some(updated_at) = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT onion_updated_at FROM friends WHERE pubkey = ?1",
|
||||||
|
params![pubkey],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if ts <= updated_at {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let n = self.conn.execute(
|
||||||
|
"UPDATE friends SET onion = ?1, onion_updated_at = ?2 WHERE pubkey = ?3",
|
||||||
|
params![onion, ts, pubkey],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
|
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
|
||||||
let n = self.conn.execute(
|
let n = self.conn.execute(
|
||||||
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
|
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
|
||||||
|
|
|
||||||
164
src/tui.rs
164
src/tui.rs
|
|
@ -1,4 +1,5 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
|
|
@ -6,15 +7,69 @@ use ratatui::style::{Color, Style};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||||
use ratatui::{DefaultTerminal, Frame};
|
use ratatui::{DefaultTerminal, Frame};
|
||||||
|
|
||||||
|
use crate::node::Node;
|
||||||
use crate::qr::{self, QrPayload};
|
use crate::qr::{self, QrPayload};
|
||||||
use crate::store::{Friend, Store};
|
use crate::store::Friend;
|
||||||
|
|
||||||
pub fn fingerprint_mismatch_banner() -> &'static str {
|
pub fn fingerprint_mismatch_banner() -> &'static str {
|
||||||
"fingerprint mismatch"
|
"fingerprint mismatch"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(store: Store) -> Result<(), String> {
|
pub fn rotate_screen_text() -> &'static str {
|
||||||
let mut app = App::new(store)?;
|
"Rotate onion address?\n\
|
||||||
|
Your identity key stays the same.\n\
|
||||||
|
Online friends get a signed location update.\n\
|
||||||
|
Offline friends CANNOT find you until they rescan your QR.\n\
|
||||||
|
Type ROTATE to confirm Esc to cancel"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RotateDecision {
|
||||||
|
Pending,
|
||||||
|
Confirm,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct RotatePrompt {
|
||||||
|
buf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RotatePrompt {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_esc(&self) -> RotateDecision {
|
||||||
|
RotateDecision::Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_char(&mut self, c: char) -> RotateDecision {
|
||||||
|
if c == '\n' {
|
||||||
|
return RotateDecision::Pending;
|
||||||
|
}
|
||||||
|
if c == '\u{8}' {
|
||||||
|
self.buf.pop();
|
||||||
|
return RotateDecision::Pending;
|
||||||
|
}
|
||||||
|
if !c.is_ascii_alphabetic() {
|
||||||
|
return RotateDecision::Pending;
|
||||||
|
}
|
||||||
|
self.buf.push(c);
|
||||||
|
if self.buf == "ROTATE" {
|
||||||
|
RotateDecision::Confirm
|
||||||
|
} else {
|
||||||
|
RotateDecision::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn typed(&self) -> &str {
|
||||||
|
&self.buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<(), String> {
|
||||||
|
let mut app = App::new(node, rt)?;
|
||||||
let mut terminal = ratatui::init();
|
let mut terminal = ratatui::init();
|
||||||
let result = app.run(&mut terminal);
|
let result = app.run(&mut terminal);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
|
|
@ -22,15 +77,15 @@ pub fn run(store: Store) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
store: Store,
|
node: Arc<Node>,
|
||||||
|
rt: tokio::runtime::Handle,
|
||||||
screen: Screen,
|
screen: Screen,
|
||||||
friends: Vec<Friend>,
|
friends: Vec<Friend>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
me_fp: String,
|
me_fp: String,
|
||||||
me_onion: String,
|
me_onion: String,
|
||||||
identity_sk: Vec<u8>,
|
|
||||||
prekey_pk: Vec<u8>,
|
|
||||||
alert: Option<String>,
|
alert: Option<String>,
|
||||||
|
status_note: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Screen {
|
enum Screen {
|
||||||
|
|
@ -38,23 +93,22 @@ enum Screen {
|
||||||
Share { art: String, payload: String },
|
Share { art: String, payload: String },
|
||||||
Paste { buf: String, err: Option<String> },
|
Paste { buf: String, err: Option<String> },
|
||||||
Approve { payload: QrPayload },
|
Approve { payload: QrPayload },
|
||||||
Rotate,
|
Rotate { prompt: RotatePrompt },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn new(store: Store) -> Result<Self, String> {
|
fn new(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<Self, String> {
|
||||||
let me = store.self_identity().map_err(|e| e.to_string())?;
|
let friends = node.list_friends()?;
|
||||||
let friends = store.list_friends().map_err(|e| e.to_string())?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
store,
|
me_fp: to_hex(&node.identity_pk()),
|
||||||
|
me_onion: node.onion(),
|
||||||
|
node,
|
||||||
|
rt,
|
||||||
screen: Screen::Main,
|
screen: Screen::Main,
|
||||||
friends,
|
friends,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
me_fp: to_hex(&me.identity_pk),
|
|
||||||
me_onion: me.onion,
|
|
||||||
identity_sk: me.identity_sk,
|
|
||||||
prekey_pk: me.prekey_pk,
|
|
||||||
alert: None,
|
alert: None,
|
||||||
|
status_note: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +146,11 @@ impl App {
|
||||||
err: None,
|
err: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
KeyCode::F(4) => self.screen = Screen::Rotate,
|
KeyCode::F(4) => {
|
||||||
|
self.screen = Screen::Rotate {
|
||||||
|
prompt: RotatePrompt::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Screen::Share { .. } => {
|
Screen::Share { .. } => {
|
||||||
|
|
@ -124,23 +182,43 @@ impl App {
|
||||||
KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main,
|
KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main,
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Screen::Rotate => {
|
Screen::Rotate { prompt } => match key.code {
|
||||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
|
KeyCode::Esc => self.screen = Screen::Main,
|
||||||
self.screen = Screen::Main;
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
if prompt.on_char(c) == RotateDecision::Confirm {
|
||||||
|
self.confirm_rotate()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Backspace => {
|
||||||
|
prompt.on_char('\u{8}');
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn confirm_rotate(&mut self) -> Result<(), String> {
|
||||||
|
let node = Arc::clone(&self.node);
|
||||||
|
match self.rt.block_on(node.rotate()) {
|
||||||
|
Ok(outcome) => {
|
||||||
|
self.me_onion = node.onion();
|
||||||
|
self.status_note = Some(format!(
|
||||||
|
"rotated · notified {}/{} friends",
|
||||||
|
outcome.notified, outcome.friends
|
||||||
|
));
|
||||||
|
self.open_share()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.alert = Some(e);
|
||||||
|
self.screen = Screen::Main;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_share(&mut self) -> Result<(), String> {
|
fn open_share(&mut self) -> Result<(), String> {
|
||||||
let onion = if self.me_onion.is_empty() {
|
let payload = self.node.qr_payload()?;
|
||||||
"unset"
|
|
||||||
} else {
|
|
||||||
self.me_onion.as_str()
|
|
||||||
};
|
|
||||||
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())?;
|
let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?;
|
||||||
self.screen = Screen::Share { art, payload };
|
self.screen = Screen::Share { art, payload };
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -155,11 +233,7 @@ impl App {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
let known = self
|
let known = self.node.get_friend(&payload.pubkey)?.is_some();
|
||||||
.store
|
|
||||||
.get_friend(&payload.pubkey)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.is_some();
|
|
||||||
if known {
|
if known {
|
||||||
self.accept_friend(&payload)?;
|
self.accept_friend(&payload)?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -171,19 +245,14 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
|
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
|
||||||
self.store
|
self.node.add_friend_payload(payload)?;
|
||||||
.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.reload_friends()?;
|
||||||
self.screen = Screen::Main;
|
self.screen = Screen::Main;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reload_friends(&mut self) -> Result<(), String> {
|
fn reload_friends(&mut self) -> Result<(), String> {
|
||||||
self.friends = self.store.list_friends().map_err(|e| e.to_string())?;
|
self.friends = self.node.list_friends()?;
|
||||||
if self.friends.is_empty() {
|
if self.friends.is_empty() {
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -198,7 +267,7 @@ impl App {
|
||||||
Screen::Share { art, payload } => draw_share(f, art, payload),
|
Screen::Share { art, payload } => draw_share(f, art, payload),
|
||||||
Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
|
Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
|
||||||
Screen::Approve { payload } => draw_approve(f, payload),
|
Screen::Approve { payload } => draw_approve(f, payload),
|
||||||
Screen::Rotate => draw_rotate(f),
|
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,8 +319,13 @@ impl App {
|
||||||
} else {
|
} else {
|
||||||
self.me_onion.as_str()
|
self.me_onion.as_str()
|
||||||
};
|
};
|
||||||
|
let note = self
|
||||||
|
.status_note
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| format!("{s} | "))
|
||||||
|
.unwrap_or_default();
|
||||||
let status = format!(
|
let status = format!(
|
||||||
"TOR: offline | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit",
|
"{note}TOR: up | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit",
|
||||||
truncate_fp(&self.me_fp),
|
truncate_fp(&self.me_fp),
|
||||||
onion
|
onion
|
||||||
);
|
);
|
||||||
|
|
@ -293,12 +367,8 @@ fn draw_approve(f: &mut Frame, payload: &QrPayload) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_rotate(f: &mut Frame) {
|
fn draw_rotate(f: &mut Frame, typed: &str) {
|
||||||
let body = "Rotate onion address?\n\
|
let body = format!("{}\n\n{typed}", rotate_screen_text());
|
||||||
Your identity key stays the same.\n\
|
|
||||||
Online friends get a signed location update.\n\
|
|
||||||
Offline friends CANNOT find you until they rescan your QR.\n\n\
|
|
||||||
M4 will rotate the HS. Esc / Enter to go back (no change).";
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(body)
|
Paragraph::new(body)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
|
|
|
||||||
132
tests/rotate.rs
Normal file
132
tests/rotate.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
//! M4: loc frames, F4 confirm typing, identity stays put.
|
||||||
|
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use onionwire::Store;
|
||||||
|
use onionwire::loc::{self, Loc};
|
||||||
|
use onionwire::tui::{RotateDecision, RotatePrompt};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
fn pk(tag: u8) -> [u8; 32] {
|
||||||
|
let mut k = [0u8; 32];
|
||||||
|
k[0] = tag;
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store() -> (tempfile::TempDir, Store) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = Store::open_at(dir.path()).expect("open");
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_loc_updates_friend_onion_keeps_fingerprint() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let pk = sk.verifying_key().to_bytes();
|
||||||
|
store
|
||||||
|
.upsert_friend(&pk, "old.onion", Some("alice"))
|
||||||
|
.unwrap();
|
||||||
|
store.set_friend_prekey(&pk, &[1u8; 32]).unwrap();
|
||||||
|
let before = store.get_friend(&pk).unwrap().unwrap();
|
||||||
|
|
||||||
|
let ts = 2_000_000_000;
|
||||||
|
let loc = loc::sign(&sk.to_bytes(), "new.onion", ts).expect("sign");
|
||||||
|
assert!(
|
||||||
|
store.apply_loc(&pk, &loc.onion, loc.ts, &loc.sig).unwrap(),
|
||||||
|
"newer signed loc must apply"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = store.get_friend(&pk).unwrap().unwrap();
|
||||||
|
assert_eq!(after.onion, "new.onion");
|
||||||
|
assert_eq!(after.fingerprint, before.fingerprint);
|
||||||
|
assert_eq!(after.petname.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(store.friend_count().unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_loc_does_not_change_row() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
store.upsert_friend(&pk(1), "old.onion", Some("c")).unwrap();
|
||||||
|
let loc = Loc {
|
||||||
|
onion: "evil.onion".into(),
|
||||||
|
ts: 2_000_000_000,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.apply_loc(&pk(1), &loc.onion, loc.ts, &loc.sig)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get_friend(&pk(1)).unwrap().unwrap().onion,
|
||||||
|
"old.onion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_loc_does_not_change_row() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let owner = SigningKey::generate(&mut OsRng);
|
||||||
|
let owner_pk = owner.verifying_key().to_bytes();
|
||||||
|
store.upsert_friend(&owner_pk, "old.onion", None).unwrap();
|
||||||
|
let other = SigningKey::generate(&mut OsRng);
|
||||||
|
let loc = loc::sign(&other.to_bytes(), "hijack.onion", 2_000_000_000).unwrap();
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.apply_loc(&owner_pk, &loc.onion, loc.ts, &loc.sig)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get_friend(&owner_pk).unwrap().unwrap().onion,
|
||||||
|
"old.onion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_ts_loc_does_not_change_row() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let pk = sk.verifying_key().to_bytes();
|
||||||
|
store.upsert_friend(&pk, "old.onion", None).unwrap();
|
||||||
|
let loc = loc::sign(&sk.to_bytes(), "new.onion", 1).unwrap();
|
||||||
|
assert!(!store.apply_loc(&pk, &loc.onion, loc.ts, &loc.sig).unwrap());
|
||||||
|
assert_eq!(store.get_friend(&pk).unwrap().unwrap().onion, "old.onion");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loc_frame_roundtrip_and_chat_is_not_loc() {
|
||||||
|
let loc = loc::sign(&[7u8; 32], "abc.onion", 42).unwrap();
|
||||||
|
let bytes = loc::encode(&loc);
|
||||||
|
let parsed = loc::decode(&bytes).expect("loc frame");
|
||||||
|
assert_eq!(parsed.onion, "abc.onion");
|
||||||
|
assert_eq!(parsed.ts, 42);
|
||||||
|
assert_eq!(parsed.sig, loc.sig);
|
||||||
|
assert!(loc::decode(b"hello wire").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f4_requires_typing_rotate_not_enter() {
|
||||||
|
let mut p = RotatePrompt::new();
|
||||||
|
assert_eq!(p.on_esc(), RotateDecision::Cancel);
|
||||||
|
assert_eq!(
|
||||||
|
p.on_char('\n'),
|
||||||
|
RotateDecision::Pending,
|
||||||
|
"Enter must not rotate"
|
||||||
|
);
|
||||||
|
for c in "ROTA".chars() {
|
||||||
|
assert_eq!(p.on_char(c), RotateDecision::Pending);
|
||||||
|
}
|
||||||
|
assert_eq!(p.on_char('T'), RotateDecision::Pending);
|
||||||
|
assert_eq!(p.on_char('E'), RotateDecision::Confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotate_prompt_text_matches_spec() {
|
||||||
|
let t = onionwire::tui::rotate_screen_text();
|
||||||
|
assert!(t.contains("Rotate onion address?"));
|
||||||
|
assert!(t.contains("Your identity key stays the same."));
|
||||||
|
assert!(t.contains("Online friends get a signed location update."));
|
||||||
|
assert!(t.contains("Offline friends CANNOT find you until they rescan your QR."));
|
||||||
|
assert!(t.contains("Type ROTATE to confirm"));
|
||||||
|
assert!(t.contains("Esc to cancel"));
|
||||||
|
}
|
||||||
119
tests/rotate_hs.rs
Normal file
119
tests/rotate_hs.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
//! M4 live: F4 rotate onion locator; identity stays; online peer updates.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use onionwire::Store;
|
||||||
|
use onionwire::node::Node;
|
||||||
|
|
||||||
|
const AFTER: &[u8] = b"after rotate";
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "needs live Tor network"]
|
||||||
|
async fn rotate_changes_onion_not_identity_and_notifies_online_peer() {
|
||||||
|
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");
|
||||||
|
let carol_home = root.path().join("carol");
|
||||||
|
|
||||||
|
eprintln!("starting alice + bob…");
|
||||||
|
let (alice, bob) = tokio::join!(
|
||||||
|
Node::start(alice_home.clone()),
|
||||||
|
Node::start(bob_home.clone())
|
||||||
|
);
|
||||||
|
let alice = alice.expect("alice");
|
||||||
|
let bob = bob.expect("bob");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
let carol = Store::open_at(&carol_home).expect("carol store");
|
||||||
|
let a_pk = alice.identity_pk();
|
||||||
|
carol
|
||||||
|
.upsert_friend(&a_pk, &alice.onion(), Some("alice"))
|
||||||
|
.expect("carol knows alice");
|
||||||
|
carol
|
||||||
|
.set_friend_prekey(&a_pk, &[0u8; 32])
|
||||||
|
.expect("carol prekey placeholder");
|
||||||
|
|
||||||
|
let fp_before = alice.identity_pk();
|
||||||
|
let onion_before = alice.onion();
|
||||||
|
eprintln!("alice onion before={onion_before}");
|
||||||
|
|
||||||
|
let outcome = alice.rotate().await.expect("rotate");
|
||||||
|
let onion_after = alice.onion();
|
||||||
|
eprintln!(
|
||||||
|
"alice onion after={onion_after} notified={}/{}",
|
||||||
|
outcome.notified, outcome.friends
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(alice.identity_pk(), fp_before, "identity key never changes");
|
||||||
|
assert_ne!(onion_after, onion_before, "onion locator must change");
|
||||||
|
assert_eq!(outcome.friends, 1, "only bob is a friend");
|
||||||
|
assert_eq!(outcome.notified, 1, "online bob got loc");
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
let bob_row = loop {
|
||||||
|
let row = bob.friend(&fp_before).expect("bob row");
|
||||||
|
if row.onion == onion_after {
|
||||||
|
break row;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
panic!(
|
||||||
|
"bob locator not updated; have {} want {onion_after}",
|
||||||
|
row.onion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(bob_row.pubkey, fp_before);
|
||||||
|
assert_eq!(bob_row.fingerprint, to_hex(&fp_before));
|
||||||
|
|
||||||
|
bob.send(&fp_before, AFTER)
|
||||||
|
.await
|
||||||
|
.expect("bob dials new locator");
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let msgs = alice.list_messages(&bob.identity_pk()).expect("alice msgs");
|
||||||
|
if msgs.iter().any(|m| m.dir == "in" && m.plaintext == AFTER) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
panic!("alice missing post-rotate message; got {msgs:?}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
alice.connect_onion(&onion_before).await.is_err(),
|
||||||
|
"old HS must be hard-cut"
|
||||||
|
);
|
||||||
|
|
||||||
|
let c_row = carol.get_friend(&a_pk).unwrap().unwrap();
|
||||||
|
assert_eq!(c_row.onion, onion_before, "offline carol stays stale");
|
||||||
|
assert_eq!(c_row.petname.as_deref(), Some("alice"));
|
||||||
|
|
||||||
|
let new_qr = alice.qr_payload().expect("new qr");
|
||||||
|
let p = onionwire::qr::decode(&new_qr).expect("decode");
|
||||||
|
carol
|
||||||
|
.upsert_friend(&p.pubkey, &p.onion, None)
|
||||||
|
.expect("rescan");
|
||||||
|
assert_eq!(carol.friend_count().unwrap(), 1);
|
||||||
|
let c2 = carol.get_friend(&a_pk).unwrap().unwrap();
|
||||||
|
assert_eq!(c2.onion, onion_after);
|
||||||
|
assert_eq!(c2.petname.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(c2.fingerprint, c_row.fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue