feat: signed friend profile frames and TUI /profile /who
prf frames are ed25519-signed, friend-visible only (unknown pubkey is ignored). /profile edits self; F5 and /who show the selected friend's last profile.
This commit is contained in:
parent
ef78be4035
commit
d304cc4c3b
7 changed files with 732 additions and 22 deletions
|
|
@ -3,9 +3,10 @@ pub mod frame;
|
|||
pub mod hs;
|
||||
pub mod loc;
|
||||
pub mod node;
|
||||
pub mod profile;
|
||||
pub mod qr;
|
||||
pub mod session;
|
||||
mod store;
|
||||
pub mod tui;
|
||||
|
||||
pub use store::{Friend, Message, SelfIdentity, Store};
|
||||
pub use store::{Friend, FriendProfile, Message, SelfIdentity, Store};
|
||||
|
|
|
|||
76
src/node.rs
76
src/node.rs
|
|
@ -13,9 +13,10 @@ use crate::dispatch::{self, Kind};
|
|||
use crate::frame;
|
||||
use crate::hs::{self, Client, HS_PORT};
|
||||
use crate::loc;
|
||||
use crate::profile;
|
||||
use crate::qr;
|
||||
use crate::session::{self, Keys};
|
||||
use crate::store::{Friend, Message, Store};
|
||||
use crate::store::{Friend, FriendProfile, Message, Store};
|
||||
|
||||
pub struct RotateResult {
|
||||
pub notified: usize,
|
||||
|
|
@ -150,6 +151,57 @@ impl Node {
|
|||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn self_profile(&self) -> Result<FriendProfile, String> {
|
||||
self.store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.self_profile()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>, String> {
|
||||
self.store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.friend_profile(pubkey)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn set_self_profile(
|
||||
&self,
|
||||
display_name: &str,
|
||||
bio: &str,
|
||||
xmr_addr: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.set_self_profile(display_name, bio, xmr_addr)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// One-shot signed `prf` to a friend. Fail closed; no retry, no outbox.
|
||||
pub async fn push_self_profile(&self, friend_pk: &[u8]) -> Result<(), String> {
|
||||
let me = self.self_profile()?;
|
||||
let prf = profile::sign(
|
||||
&self.keys.identity_sk,
|
||||
&me.display_name,
|
||||
&me.bio,
|
||||
&me.xmr_addr,
|
||||
me.updated_at,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pt = profile::encode(&prf);
|
||||
let (onion, prekey) = {
|
||||
let f = self.friend(friend_pk)?;
|
||||
if f.prekey.len() != 32 {
|
||||
return Err("friend missing prekey".into());
|
||||
}
|
||||
(f.onion, f.prekey)
|
||||
};
|
||||
self.try_send(&onion, friend_pk, &prekey, &pt).await
|
||||
}
|
||||
|
||||
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
|
|
@ -320,7 +372,25 @@ impl Node {
|
|||
);
|
||||
match applied {
|
||||
Ok(true) => {}
|
||||
Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"),
|
||||
Ok(false) => {
|
||||
eprintln!("loc dropped (bad sig, stale ts, or unknown friend)")
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Kind::Profile => {
|
||||
if let Some(prf) = profile::decode(&pt) {
|
||||
let applied = store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.apply_profile(&sess.peer_identity, &prf);
|
||||
match applied {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
eprintln!("prf dropped (bad sig, stale ts, or unknown friend)")
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -334,7 +404,7 @@ impl Node {
|
|||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
Kind::Profile | Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()),
|
||||
Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
src/profile.rs
Normal file
157
src/profile.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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 Profile {
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub xmr_addr: String,
|
||||
pub ts: i64,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
const PREFIX: &[u8] = b"prf ";
|
||||
const NAME_MAX: usize = 64;
|
||||
const BIO_MAX: usize = 512;
|
||||
|
||||
pub fn validate(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||
check_fields(display_name, bio, xmr_addr)
|
||||
}
|
||||
|
||||
pub fn sign(
|
||||
identity_sk: &[u8],
|
||||
display_name: &str,
|
||||
bio: &str,
|
||||
xmr_addr: &str,
|
||||
ts: i64,
|
||||
) -> Result<Profile> {
|
||||
check_fields(display_name, bio, xmr_addr)?;
|
||||
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(display_name, bio, xmr_addr, ts))
|
||||
.to_bytes()
|
||||
.to_vec();
|
||||
Ok(Profile {
|
||||
display_name: display_name.to_string(),
|
||||
bio: bio.to_string(),
|
||||
xmr_addr: xmr_addr.to_string(),
|
||||
ts,
|
||||
sig,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify(identity_pk: &[u8], prf: &Profile) -> bool {
|
||||
if check_fields(&prf.display_name, &prf.bio, &prf.xmr_addr).is_err() {
|
||||
return false;
|
||||
}
|
||||
if identity_pk.len() != 32 || prf.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 prf.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(&prf.display_name, &prf.bio, &prf.xmr_addr, prf.ts),
|
||||
&Signature::from_bytes(&sig),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn encode(prf: &Profile) -> Vec<u8> {
|
||||
let mut out = Vec::from(PREFIX);
|
||||
out.extend_from_slice(prf.display_name.as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(prf.bio.as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(prf.xmr_addr.as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(prf.ts.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(to_hex(&prf.sig).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(pt: &[u8]) -> Option<Profile> {
|
||||
let rest = pt.strip_prefix(PREFIX)?;
|
||||
let text = std::str::from_utf8(rest).ok()?;
|
||||
let mut parts = text.splitn(5, '\n');
|
||||
let display_name = parts.next()?.to_string();
|
||||
let bio = parts.next()?.to_string();
|
||||
let xmr_addr = parts.next()?.to_string();
|
||||
let ts = parts.next()?.parse::<i64>().ok()?;
|
||||
let sig = from_hex(parts.next()?)?;
|
||||
if check_fields(&display_name, &bio, &xmr_addr).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(Profile {
|
||||
display_name,
|
||||
bio,
|
||||
xmr_addr,
|
||||
ts,
|
||||
sig,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||
if display_name.len() > NAME_MAX {
|
||||
return Err(Error("display_name longer than 64 bytes".into()));
|
||||
}
|
||||
if bio.len() > BIO_MAX {
|
||||
return Err(Error("bio longer than 512 bytes".into()));
|
||||
}
|
||||
if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
|
||||
return Err(Error("profile fields must not contain newlines".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sign_msg(display_name: &str, bio: &str, xmr_addr: &str, ts: i64) -> Vec<u8> {
|
||||
let mut msg = Vec::from(display_name.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(bio.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(xmr_addr.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()
|
||||
}
|
||||
104
src/store.rs
104
src/store.rs
|
|
@ -54,6 +54,13 @@ pub struct Friend {
|
|||
pub prekey: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct FriendProfile {
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub xmr_addr: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Message {
|
||||
pub dir: String,
|
||||
|
|
@ -105,6 +112,21 @@ impl Store {
|
|||
prekey_pk BLOB NOT NULL,
|
||||
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS self_profile (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
xmr_addr TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS friend_profiles (
|
||||
pubkey BLOB PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL,
|
||||
xmr_addr TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
|
||||
",
|
||||
)?;
|
||||
let store = Self { conn };
|
||||
|
|
@ -304,6 +326,79 @@ impl Store {
|
|||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||
crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?;
|
||||
let prev: i64 = self.conn.query_row(
|
||||
"SELECT updated_at FROM self_profile WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let now = unix_now().max(prev + 1);
|
||||
self.conn.execute(
|
||||
"UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1",
|
||||
params![display_name, bio, xmr_addr, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn self_profile(&self) -> Result<FriendProfile> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1",
|
||||
[],
|
||||
profile_from_row,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert.
|
||||
pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result<bool> {
|
||||
if !crate::profile::verify(pubkey, prf) {
|
||||
return Ok(false);
|
||||
}
|
||||
let exists: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM friends WHERE pubkey = ?1",
|
||||
params![pubkey],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if exists == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let prev: Option<i64> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT updated_at FROM friend_profiles WHERE pubkey = ?1",
|
||||
params![pubkey],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if prev.is_some_and(|t| prf.ts <= t) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.conn.execute(
|
||||
"INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(pubkey) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
bio = excluded.bio,
|
||||
xmr_addr = excluded.xmr_addr,
|
||||
updated_at = excluded.updated_at",
|
||||
params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1",
|
||||
params![pubkey],
|
||||
profile_from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
|
||||
let n = self.conn.execute(
|
||||
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
|
||||
|
|
@ -385,6 +480,15 @@ impl Store {
|
|||
}
|
||||
}
|
||||
|
||||
fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FriendProfile> {
|
||||
Ok(FriendProfile {
|
||||
display_name: row.get(0)?,
|
||||
bio: row.get(1)?,
|
||||
xmr_addr: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
||||
Ok(Friend {
|
||||
pubkey: row.get(0)?,
|
||||
|
|
|
|||
251
src/tui.rs
251
src/tui.rs
|
|
@ -9,7 +9,7 @@ use ratatui::{DefaultTerminal, Frame};
|
|||
|
||||
use crate::node::Node;
|
||||
use crate::qr::{self, QrPayload};
|
||||
use crate::store::Friend;
|
||||
use crate::store::{Friend, FriendProfile};
|
||||
|
||||
const C_ACCENT: Color = Color::Rgb(232, 165, 75);
|
||||
const C_TEXT: Color = Color::Rgb(196, 214, 176);
|
||||
|
|
@ -59,17 +59,47 @@ OnionWire keys\n\
|
|||
1 / 2 / 3 focus roster / chat / composer\n\
|
||||
j k or Up Down move or scroll focused pane\n\
|
||||
g / G jump to top / bottom\n\
|
||||
Enter /wipe /wipe-all from composer\n\
|
||||
Enter /wipe /wipe-all /profile /who\n\
|
||||
Esc close overlay, go back, clear composer\n\
|
||||
F2 share QR\n\
|
||||
F3 paste a friend QR\n\
|
||||
F4 rotate onion locator\n\
|
||||
F5 selected friend's profile (/who)\n\
|
||||
/profile edit name, bio, Monero address\n\
|
||||
? this help\n\
|
||||
Ctrl-Q quit\n\
|
||||
\n\
|
||||
Esc closes this overlay"
|
||||
}
|
||||
|
||||
pub fn who_overlay_text(fingerprint: &str, onion: &str, profile: Option<&FriendProfile>) -> String {
|
||||
match profile {
|
||||
None => format!(
|
||||
"friend profile\n\nfp {fingerprint}\nonion {onion}\n\nno profile yet\n\nEsc closes"
|
||||
),
|
||||
Some(p) => {
|
||||
let name = if p.display_name.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
p.display_name.as_str()
|
||||
};
|
||||
let bio = if p.bio.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
p.bio.as_str()
|
||||
};
|
||||
let xmr = if p.xmr_addr.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
p.xmr_addr.as_str()
|
||||
};
|
||||
format!(
|
||||
"friend profile\n\nname {name}\nbio {bio}\nxmr {xmr}\nfp {fingerprint}\nonion {onion}\n\nEsc closes"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn banner_for_width(width: u16) -> &'static str {
|
||||
let full = wordmark_banner();
|
||||
let max_line = full.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
||||
|
|
@ -111,6 +141,90 @@ pub enum WipeKind {
|
|||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlashCmd {
|
||||
Wipe(WipeKind),
|
||||
Profile,
|
||||
Who,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileDraft {
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub xmr_addr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileEditor {
|
||||
name: String,
|
||||
bio: String,
|
||||
xmr: String,
|
||||
field: usize,
|
||||
}
|
||||
|
||||
impl ProfileEditor {
|
||||
pub fn new(display_name: &str, bio: &str, xmr_addr: &str) -> Self {
|
||||
Self {
|
||||
name: display_name.to_string(),
|
||||
bio: bio.to_string(),
|
||||
xmr: xmr_addr.to_string(),
|
||||
field: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_enter(&self) -> ProfileDraft {
|
||||
ProfileDraft {
|
||||
display_name: self.name.clone(),
|
||||
bio: self.bio.clone(),
|
||||
xmr_addr: self.xmr.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_tab(&mut self) {
|
||||
self.field = (self.field + 1) % 3;
|
||||
}
|
||||
|
||||
pub fn on_backtab(&mut self) {
|
||||
self.field = (self.field + 2) % 3;
|
||||
}
|
||||
|
||||
pub fn on_char(&mut self, c: char) {
|
||||
if c == '\n' {
|
||||
return;
|
||||
}
|
||||
self.field_mut().push(c);
|
||||
}
|
||||
|
||||
pub fn on_backspace(&mut self) {
|
||||
self.field_mut().pop();
|
||||
}
|
||||
|
||||
pub fn field(&self) -> usize {
|
||||
self.field
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn bio(&self) -> &str {
|
||||
&self.bio
|
||||
}
|
||||
|
||||
pub fn xmr_addr(&self) -> &str {
|
||||
&self.xmr
|
||||
}
|
||||
|
||||
fn field_mut(&mut self) -> &mut String {
|
||||
match self.field {
|
||||
0 => &mut self.name,
|
||||
1 => &mut self.bio,
|
||||
_ => &mut self.xmr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WipeDecision {
|
||||
Pending,
|
||||
|
|
@ -124,10 +238,19 @@ pub enum AppExit {
|
|||
WipeAll,
|
||||
}
|
||||
|
||||
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||
pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
|
||||
match raw.trim() {
|
||||
"/wipe" => Some(WipeKind::Messages),
|
||||
"/wipe-all" => Some(WipeKind::All),
|
||||
"/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)),
|
||||
"/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)),
|
||||
"/profile" => Some(SlashCmd::Profile),
|
||||
"/who" => Some(SlashCmd::Who),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||
match parse_cmd(raw) {
|
||||
Some(SlashCmd::Wipe(k)) => Some(k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -262,6 +385,7 @@ struct App {
|
|||
composer: String,
|
||||
focus: Pane,
|
||||
help_open: bool,
|
||||
who_open: bool,
|
||||
chat_scroll: u16,
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +396,7 @@ enum Screen {
|
|||
Approve { payload: QrPayload },
|
||||
Rotate { prompt: RotatePrompt },
|
||||
Wipe { kind: WipeKind, prompt: WipePrompt },
|
||||
Profile { editor: ProfileEditor },
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -290,6 +415,7 @@ impl App {
|
|||
composer: String::new(),
|
||||
focus: Pane::Composer,
|
||||
help_open: false,
|
||||
who_open: false,
|
||||
chat_scroll: 0,
|
||||
})
|
||||
}
|
||||
|
|
@ -320,12 +446,25 @@ impl App {
|
|||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if self.who_open {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::F(5) => self.who_open = false,
|
||||
KeyCode::Char('?') => {
|
||||
self.who_open = false;
|
||||
self.help_open = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if key.code == KeyCode::Char('?') && !matches!(self.screen, Screen::Paste { .. }) {
|
||||
self.help_open = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut wipe_confirm = None;
|
||||
let mut profile_save = None;
|
||||
let mut open_profile = false;
|
||||
match &mut self.screen {
|
||||
Screen::Main => match key.code {
|
||||
KeyCode::Tab => self.focus = self.focus.next(),
|
||||
|
|
@ -344,14 +483,15 @@ impl App {
|
|||
prompt: RotatePrompt::new(),
|
||||
};
|
||||
}
|
||||
KeyCode::F(5) => self.who_open = true,
|
||||
KeyCode::Esc => self.composer.clear(),
|
||||
KeyCode::Backspace => {
|
||||
if self.focus == Pane::Composer {
|
||||
self.composer.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(kind) = parse_slash(&self.composer) {
|
||||
KeyCode::Enter => match parse_cmd(&self.composer) {
|
||||
Some(SlashCmd::Wipe(kind)) => {
|
||||
self.composer.clear();
|
||||
let prompt = match kind {
|
||||
WipeKind::Messages => WipePrompt::messages(),
|
||||
|
|
@ -359,7 +499,16 @@ impl App {
|
|||
};
|
||||
self.screen = Screen::Wipe { kind, prompt };
|
||||
}
|
||||
Some(SlashCmd::Profile) => {
|
||||
self.composer.clear();
|
||||
open_profile = true;
|
||||
}
|
||||
Some(SlashCmd::Who) => {
|
||||
self.composer.clear();
|
||||
self.who_open = true;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.handle_main_char(c);
|
||||
}
|
||||
|
|
@ -421,10 +570,27 @@ impl App {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
Screen::Profile { editor } => match key.code {
|
||||
KeyCode::Esc => self.screen = Screen::Main,
|
||||
KeyCode::Tab => editor.on_tab(),
|
||||
KeyCode::BackTab => editor.on_backtab(),
|
||||
KeyCode::Backspace => editor.on_backspace(),
|
||||
KeyCode::Enter => profile_save = Some(editor.on_enter()),
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
editor.on_char(c);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
if let Some(kind) = wipe_confirm {
|
||||
return self.confirm_wipe(kind);
|
||||
}
|
||||
if open_profile {
|
||||
self.open_profile()?;
|
||||
}
|
||||
if let Some(draft) = profile_save {
|
||||
self.save_profile(draft)?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -573,6 +739,43 @@ impl App {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn open_profile(&mut self) -> Result<(), String> {
|
||||
let p = self.node.self_profile()?;
|
||||
self.screen = Screen::Profile {
|
||||
editor: ProfileEditor::new(&p.display_name, &p.bio, &p.xmr_addr),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_profile(&mut self, draft: ProfileDraft) -> Result<(), String> {
|
||||
if let Err(e) = self
|
||||
.node
|
||||
.set_self_profile(&draft.display_name, &draft.bio, &draft.xmr_addr)
|
||||
{
|
||||
self.alert = Some(e);
|
||||
return Ok(());
|
||||
}
|
||||
self.screen = Screen::Main;
|
||||
if let Some(friend) = self.friends.get(self.selected) {
|
||||
let pk = friend.pubkey.clone();
|
||||
match self.rt.block_on(self.node.push_self_profile(&pk)) {
|
||||
Ok(()) => self.status_note = Some("profile saved · sent".into()),
|
||||
Err(e) => self.status_note = Some(format!("profile send failed: {e}")),
|
||||
}
|
||||
} else {
|
||||
self.status_note = Some("profile saved".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn who_body(&self) -> String {
|
||||
let Some(friend) = self.friends.get(self.selected) else {
|
||||
return "no friend selected\n\nEsc closes".into();
|
||||
};
|
||||
let profile = self.node.friend_profile(&friend.pubkey).ok().flatten();
|
||||
who_overlay_text(&friend.fingerprint, &friend.onion, profile.as_ref())
|
||||
}
|
||||
|
||||
fn draw(&self, f: &mut Frame) {
|
||||
match &self.screen {
|
||||
Screen::Main => self.draw_main(f),
|
||||
|
|
@ -581,9 +784,12 @@ impl App {
|
|||
Screen::Approve { payload } => draw_approve(f, payload),
|
||||
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
||||
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
||||
Screen::Profile { editor } => draw_profile(f, editor),
|
||||
}
|
||||
if self.help_open {
|
||||
draw_help_overlay(f);
|
||||
draw_text_overlay(f, "? help", help_overlay_text());
|
||||
} else if self.who_open {
|
||||
draw_text_overlay(f, "/who", &self.who_body());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -688,7 +894,7 @@ impl App {
|
|||
f.render_widget(chat, panes[1]);
|
||||
|
||||
let cmd = if self.composer.is_empty() {
|
||||
"/wipe /wipe-all".to_string()
|
||||
"/wipe /wipe-all /profile /who".to_string()
|
||||
} else {
|
||||
self.composer.clone()
|
||||
};
|
||||
|
|
@ -803,7 +1009,7 @@ fn join_footer(parts: &[&str]) -> String {
|
|||
|
||||
/// Main-screen key hints. Kept short so width 80 still shows TOR + identity + `? help`.
|
||||
pub fn main_footer_hints() -> &'static str {
|
||||
"Tab F2 F3 F4 ? help Ctrl-Q"
|
||||
"Tab F2 F3 F4 F5 ? help Ctrl-Q"
|
||||
}
|
||||
|
||||
fn footer_hints(screen: &Screen) -> &'static str {
|
||||
|
|
@ -817,6 +1023,7 @@ fn footer_hints(screen: &Screen) -> &'static str {
|
|||
WipeKind::Messages => "type WIPE Esc cancel ? help",
|
||||
WipeKind::All => "type WIPEALL Esc cancel ? help",
|
||||
},
|
||||
Screen::Profile { .. } => "Enter save Tab field Esc cancel ? help",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -898,9 +1105,27 @@ fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
|
|||
);
|
||||
}
|
||||
|
||||
fn draw_help_overlay(f: &mut Frame) {
|
||||
fn draw_profile(f: &mut Frame, editor: &ProfileEditor) {
|
||||
let labels = ["name", "bio", "xmr"];
|
||||
let values = [editor.display_name(), editor.bio(), editor.xmr_addr()];
|
||||
let mut body = String::from(
|
||||
"Your profile (friend-visible)\nEnter saves Esc cancels Tab next field\n",
|
||||
);
|
||||
for (i, (label, value)) in labels.iter().zip(values).enumerate() {
|
||||
let mark = if editor.field() == i { ">" } else { " " };
|
||||
body.push_str(&format!("\n{mark} {label}: {value}"));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(body)
|
||||
.style(Style::default().fg(C_TEXT))
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(themed_block("(o) profile")),
|
||||
f.area(),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) {
|
||||
let area = f.area();
|
||||
let text = help_overlay_text();
|
||||
let lines = text.lines().count() as u16 + 2;
|
||||
let text_w = text
|
||||
.lines()
|
||||
|
|
@ -920,7 +1145,7 @@ fn draw_help_overlay(f: &mut Frame) {
|
|||
f.render_widget(
|
||||
Paragraph::new(text)
|
||||
.style(Style::default().fg(C_TEXT))
|
||||
.block(themed_block("? help")),
|
||||
.block(themed_block(title)),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
153
tests/profile.rs
Normal file
153
tests/profile.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! M7: signed friend-visible profile frames + slash parse.
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use onionwire::Store;
|
||||
use onionwire::profile::{self};
|
||||
use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
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_profile_upserts_friend_row_keeps_fingerprint() {
|
||||
let (_dir, store) = store();
|
||||
let sk = SigningKey::generate(&mut OsRng);
|
||||
let pk = sk.verifying_key().to_bytes();
|
||||
store
|
||||
.upsert_friend(&pk, "alice.onion", Some("alice"))
|
||||
.unwrap();
|
||||
let before = store.get_friend(&pk).unwrap().unwrap();
|
||||
|
||||
let prf = profile::sign(&sk.to_bytes(), "Ali", "hi", "", 2_000_000_000).expect("sign");
|
||||
assert!(
|
||||
store.apply_profile(&pk, &prf).unwrap(),
|
||||
"newer signed profile must apply"
|
||||
);
|
||||
|
||||
let after = store.get_friend(&pk).unwrap().unwrap();
|
||||
assert_eq!(after.fingerprint, before.fingerprint);
|
||||
assert_eq!(after.petname.as_deref(), Some("alice"));
|
||||
assert_eq!(after.onion, "alice.onion");
|
||||
assert_eq!(store.friend_count().unwrap(), 1);
|
||||
|
||||
let got = store.friend_profile(&pk).unwrap().expect("row");
|
||||
assert_eq!(got.display_name, "Ali");
|
||||
assert_eq!(got.bio, "hi");
|
||||
assert_eq!(got.xmr_addr, "");
|
||||
assert_eq!(got.updated_at, 2_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_profile_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 first = profile::sign(&owner.to_bytes(), "real", "", "", 2_000_000_000).unwrap();
|
||||
assert!(store.apply_profile(&owner_pk, &first).unwrap());
|
||||
|
||||
let other = SigningKey::generate(&mut OsRng);
|
||||
let spoof = profile::sign(&other.to_bytes(), "evil", "no", "", 2_000_000_001).unwrap();
|
||||
assert!(!store.apply_profile(&owner_pk, &spoof).unwrap());
|
||||
let got = store.friend_profile(&owner_pk).unwrap().unwrap();
|
||||
assert_eq!(got.display_name, "real");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_ts_profile_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 newer = profile::sign(&sk.to_bytes(), "new", "", "", 100).unwrap();
|
||||
assert!(store.apply_profile(&pk, &newer).unwrap());
|
||||
let stale = profile::sign(&sk.to_bytes(), "old", "", "", 50).unwrap();
|
||||
assert!(!store.apply_profile(&pk, &stale).unwrap());
|
||||
assert_eq!(
|
||||
store.friend_profile(&pk).unwrap().unwrap().display_name,
|
||||
"new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pubkey_profile_is_ignored() {
|
||||
let (_dir, store) = store();
|
||||
let sk = SigningKey::generate(&mut OsRng);
|
||||
let pk = sk.verifying_key().to_bytes();
|
||||
let prf = profile::sign(&sk.to_bytes(), "ghost", "", "", 2_000_000_000).unwrap();
|
||||
assert!(!store.apply_profile(&pk, &prf).unwrap());
|
||||
assert!(store.friend_profile(&pk).unwrap().is_none());
|
||||
assert_eq!(store.friend_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bio_over_512_rejected() {
|
||||
let bio = "x".repeat(513);
|
||||
let sk = [7u8; 32];
|
||||
assert!(profile::sign(&sk, "n", &bio, "", 1).is_err());
|
||||
let mut out = b"prf n\n".to_vec();
|
||||
out.extend_from_slice(bio.as_bytes());
|
||||
out.extend_from_slice(b"\n\n1\n");
|
||||
out.extend_from_slice("aa".repeat(64).as_bytes());
|
||||
assert!(profile::decode(&out).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_in_name_rejected() {
|
||||
let sk = [7u8; 32];
|
||||
assert!(profile::sign(&sk, "bad\nname", "bio", "", 1).is_err());
|
||||
assert!(profile::decode(b"prf bad\nname\nbio\n\n1\n00").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_is_not_profile() {
|
||||
assert!(profile::decode(b"hello wire").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_profile_roundtrip() {
|
||||
let (_dir, store) = store();
|
||||
store.set_self_profile("me", "a bio", "4abc").unwrap();
|
||||
let got = store.self_profile().unwrap();
|
||||
assert_eq!(got.display_name, "me");
|
||||
assert_eq!(got.bio, "a bio");
|
||||
assert_eq!(got.xmr_addr, "4abc");
|
||||
assert!(got.updated_at > 0);
|
||||
store.set_self_profile("", "", "").unwrap();
|
||||
let empty = store.self_profile().unwrap();
|
||||
assert_eq!(empty.display_name, "");
|
||||
assert!(empty.updated_at >= got.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_profile_who_and_wipe_still_parse() {
|
||||
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));
|
||||
assert_eq!(parse_cmd(" /profile "), Some(SlashCmd::Profile));
|
||||
assert_eq!(parse_cmd("/who"), Some(SlashCmd::Who));
|
||||
assert_eq!(parse_cmd("/who\n"), Some(SlashCmd::Who));
|
||||
assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages)));
|
||||
assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages));
|
||||
assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All));
|
||||
assert_eq!(parse_slash("/profile"), None);
|
||||
assert_eq!(parse_cmd("/rotate"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_save_empty_name_ok_no_confirm_phrase() {
|
||||
let saved = onionwire::tui::ProfileEditor::new("", "", "").on_enter();
|
||||
assert_eq!(saved.display_name, "");
|
||||
assert_eq!(saved.bio, "");
|
||||
assert_eq!(saved.xmr_addr, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_empty_says_no_profile_yet() {
|
||||
let t = onionwire::tui::who_overlay_text("abcd", "x.onion", None);
|
||||
assert!(t.contains("no profile yet"));
|
||||
assert!(t.contains("abcd"));
|
||||
assert!(t.contains("x.onion"));
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
|
||||
|
||||
use onionwire::tui::{
|
||||
banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
|
||||
status_footer, wordmark_banner, Pane,
|
||||
Pane, banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
|
||||
status_footer, wordmark_banner,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -38,7 +38,7 @@ fn onion_glyph_is_nonempty() {
|
|||
fn help_overlay_lists_core_bindings() {
|
||||
let help = help_overlay_text();
|
||||
assert!(!help.is_empty());
|
||||
for needle in ["Tab", "F2", "F3", "F4", "Ctrl-Q", "?"] {
|
||||
for needle in ["Tab", "F2", "F3", "F4", "F5", "/profile", "Ctrl-Q", "?"] {
|
||||
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
||||
}
|
||||
for line in help.lines() {
|
||||
|
|
@ -61,10 +61,10 @@ fn banner_for_width_collapses_when_narrow() {
|
|||
|
||||
#[test]
|
||||
fn chrome_renders_at_80x24_and_120x40() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Terminal;
|
||||
|
||||
let fp = "abcdef0123456789";
|
||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue