feat: signed friend profile frames and TUI /profile /who
Some checks failed
ci / test (push) Waiting to run
release / aarch64 (push) Failing after 14m14s

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:
Sirius DevOps 2026-09-10 14:25:31 -04:00
parent ef78be4035
commit d304cc4c3b
No known key found for this signature in database
7 changed files with 732 additions and 22 deletions

View file

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

View file

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

View file

@ -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)?,

View file

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

View file

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