[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:
Sirius DevOps 2026-09-10 02:43:45 -04:00
parent a2dfe3b2b8
commit 07aa97711c
No known key found for this signature in database
8 changed files with 743 additions and 87 deletions

View file

@ -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
View 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()
}

View file

@ -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())?
}

View file

@ -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()

View file

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

View file

@ -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
View 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
View 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()
}