#![allow(clippy::redundant_pub_crate)]
use super::helpers::add_local_event;
use super::types::{C_CMD, C_DIM, C_ERR, C_HEADER, C_RST, C_TEXT, divider};
use crate::app::App;
use crate::e2e::crypto::fingerprint::{fingerprint_bip39, fingerprint_hex};
use crate::e2e::keyring::{ChannelConfig, ChannelMode, IncomingSession, TrustStatus};
use crate::state::buffer::{Message, MessageType};
use chrono::Utc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum E2eEventLevel {
Info,
Warning,
Error,
}
impl E2eEventLevel {
const fn event_key(self) -> &'static str {
match self {
Self::Info => "e2e_info",
Self::Warning => "e2e_warning",
Self::Error => "e2e_error",
}
}
}
fn e2e_event(app: &mut App, level: E2eEventLevel, text: &str) {
let Some(active_id) = app.state.active_buffer_id.as_deref() else {
return;
};
let active_id = active_id.to_string();
let id = app.state.next_message_id();
app.state.add_local_message(
&active_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.to_string(),
highlight: level == E2eEventLevel::Error,
event_key: Some(level.event_key().to_string()),
event_params: Some(vec![text.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AutotrustOp {
List,
Add(String, String),
Remove(String),
Usage(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum E2eSub {
On,
Off,
Mode(String),
Accept(String),
Decline(String),
Handshake(String),
Revoke(String),
Unrevoke(String),
Forget {
target: String,
all: bool,
},
Autotrust(AutotrustOp),
List {
all: bool,
},
Status,
Fingerprint,
Verify(String),
Reverify(String),
Rotate,
Export(Option<String>),
Import(Option<String>),
Help,
None,
Unknown(String),
Usage(&'static str),
}
pub(crate) fn parse_subcommand(args: &[String]) -> E2eSub {
let Some(sub_raw) = args.first() else {
return E2eSub::None;
};
let sub = sub_raw.to_lowercase();
let rest = &args[1..];
match sub.as_str() {
"on" => E2eSub::On,
"off" => E2eSub::Off,
"mode" => rest
.first()
.map_or(E2eSub::Usage("/e2e mode <auto-accept|normal|quiet>"), |m| {
E2eSub::Mode(m.clone())
}),
"accept" => rest
.first()
.map_or(E2eSub::Usage("/e2e accept <nick>"), |n| {
E2eSub::Accept(n.clone())
}),
"decline" => rest
.first()
.map_or(E2eSub::Usage("/e2e decline <nick>"), |n| {
E2eSub::Decline(n.clone())
}),
"handshake" => rest
.first()
.map_or(E2eSub::Usage("/e2e handshake <nick>"), |n| {
E2eSub::Handshake(n.clone())
}),
"revoke" => rest
.first()
.map_or(E2eSub::Usage("/e2e revoke <nick>"), |n| {
E2eSub::Revoke(n.clone())
}),
"unrevoke" => rest
.first()
.map_or(E2eSub::Usage("/e2e unrevoke <nick>"), |n| {
E2eSub::Unrevoke(n.clone())
}),
"forget" => parse_forget_subcommand(rest),
"autotrust" => E2eSub::Autotrust(parse_autotrust_op(rest)),
"list" => E2eSub::List {
all: rest
.first()
.is_some_and(|arg| arg.eq_ignore_ascii_case("-all")),
},
"status" => E2eSub::Status,
"fingerprint" => E2eSub::Fingerprint,
"verify" => rest
.first()
.map_or(E2eSub::Usage("/e2e verify <nick>"), |n| {
E2eSub::Verify(n.clone())
}),
"reverify" => rest
.first()
.map_or(E2eSub::Usage("/e2e reverify <nick>"), |n| {
E2eSub::Reverify(n.clone())
}),
"rotate" => E2eSub::Rotate,
"export" => E2eSub::Export(rest.first().cloned()),
"import" => E2eSub::Import(rest.first().cloned()),
"help" | "?" => E2eSub::Help,
other => E2eSub::Unknown(other.to_string()),
}
}
fn parse_forget_subcommand(rest: &[String]) -> E2eSub {
if rest.is_empty() {
return E2eSub::Usage("/e2e forget [-all] <nick|handle>");
}
let mut all = false;
let mut target: Option<String> = None;
for arg in rest {
if arg.eq_ignore_ascii_case("-all") {
all = true;
} else if target.is_none() {
target = Some(arg.clone());
} else {
return E2eSub::Usage("/e2e forget [-all] <nick|handle>");
}
}
target.map_or(
E2eSub::Usage("/e2e forget [-all] <nick|handle>"),
|target| E2eSub::Forget { target, all },
)
}
fn parse_autotrust_op(rest: &[String]) -> AutotrustOp {
let Some(op_raw) = rest.first() else {
return AutotrustOp::Usage("/e2e autotrust <list|add|remove> [scope] [pattern]");
};
let op = op_raw.to_lowercase();
match op.as_str() {
"list" => AutotrustOp::List,
"add" => match (rest.get(1), rest.get(2)) {
(Some(scope), Some(pat)) => AutotrustOp::Add(scope.clone(), pat.clone()),
_ => AutotrustOp::Usage("/e2e autotrust add <scope> <pattern>"),
},
"remove" => rest.get(1).map_or(
AutotrustOp::Usage("/e2e autotrust remove <pattern>"),
|pat| AutotrustOp::Remove(pat.clone()),
),
_ => AutotrustOp::Usage("/e2e autotrust <list|add|remove>"),
}
}
pub(crate) fn parse_mode(s: &str) -> std::result::Result<ChannelMode, String> {
match s.to_lowercase().as_str() {
"auto-accept" | "auto" => Ok(ChannelMode::AutoAccept),
"normal" => Ok(ChannelMode::Normal),
"quiet" => Ok(ChannelMode::Quiet),
other => Err(format!(
"invalid mode '{other}' (expected auto-accept|normal|quiet)"
)),
}
}
pub(crate) fn cmd_e2e(app: &mut App, args: &[String]) {
let sub = parse_subcommand(args);
match sub {
E2eSub::None | E2eSub::Help => e2e_help(app),
E2eSub::On => e2e_on(app),
E2eSub::Off => e2e_off(app),
E2eSub::Mode(m) => e2e_mode(app, &m),
E2eSub::Accept(nick) => e2e_accept(app, &nick),
E2eSub::Decline(nick) => e2e_decline(app, &nick),
E2eSub::Handshake(nick) => e2e_handshake(app, &nick),
E2eSub::Revoke(nick) => e2e_revoke(app, &nick),
E2eSub::Unrevoke(nick) => e2e_unrevoke(app, &nick),
E2eSub::Forget { target, all } => e2e_forget(app, &target, all),
E2eSub::Autotrust(op) => e2e_autotrust(app, op),
E2eSub::List { all } => e2e_list(app, all),
E2eSub::Status => e2e_status(app),
E2eSub::Fingerprint => e2e_fingerprint(app),
E2eSub::Verify(nick) => e2e_verify(app, &nick),
E2eSub::Reverify(nick) => e2e_reverify(app, &nick),
E2eSub::Rotate => e2e_rotate(app),
E2eSub::Export(path) => e2e_export(app, path.as_deref()),
E2eSub::Import(path) => e2e_import(app, path.as_deref()),
E2eSub::Unknown(other) => {
err(app, &format!("unknown subcommand: {other}"));
e2e_help(app);
}
E2eSub::Usage(hint) => {
err(app, &format!("usage: {hint}"));
}
}
if !app.state.pending_e2e_sends.is_empty() {
app.drain_pending_e2e_sends();
}
}
fn current_channel(app: &App) -> Option<String> {
use crate::state::buffer::BufferType;
let buf = app.state.active_buffer()?;
if matches!(buf.buffer_type, BufferType::Channel | BufferType::Query) {
Some(buf.name.clone())
} else {
None
}
}
fn require_mgr(app: &mut App) -> Option<std::sync::Arc<crate::e2e::E2eManager>> {
let mgr = app.state.e2e_manager.clone();
if mgr.is_none() {
err(
app,
"manager not initialized (check logging.enabled / e2e.enabled)",
);
}
mgr
}
fn err(app: &mut App, msg: &str) {
e2e_event(app, E2eEventLevel::Error, msg);
}
fn ok(app: &mut App, msg: &str) {
e2e_event(app, E2eEventLevel::Info, msg);
}
fn warn(app: &mut App, msg: &str) {
e2e_event(app, E2eEventLevel::Warning, msg);
}
fn e2e_on(app: &mut App) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e on: no active channel");
return;
};
let Some(mgr) = require_mgr(app) else { return };
let cfg = ChannelConfig {
channel: chan.clone(),
enabled: true,
mode: ChannelMode::Normal,
};
if let Err(e) = mgr.keyring().set_channel_config(&cfg) {
err(app, &format!("/e2e on: {e}"));
return;
}
ok(app, &format!("enabled on {chan} (mode=normal)"));
}
fn e2e_off(app: &mut App) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e off: no active channel");
return;
};
let Some(mgr) = require_mgr(app) else { return };
let cfg = ChannelConfig {
channel: chan.clone(),
enabled: false,
mode: ChannelMode::Normal,
};
if let Err(e) = mgr.keyring().set_channel_config(&cfg) {
err(app, &format!("/e2e off: {e}"));
return;
}
ok(app, &format!("disabled on {chan}"));
}
fn e2e_mode(app: &mut App, mode_str: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e mode: no active channel");
return;
};
let mode = match parse_mode(mode_str) {
Ok(m) => m,
Err(e) => {
err(app, &format!("/e2e mode: {e}"));
return;
}
};
let Some(mgr) = require_mgr(app) else { return };
let cfg = ChannelConfig {
channel: chan.clone(),
enabled: true,
mode,
};
if let Err(e) = mgr.keyring().set_channel_config(&cfg) {
err(app, &format!("/e2e mode: {e}"));
return;
}
ok(app, &format!("mode={} on {chan}", mode.as_str()));
}
fn e2e_accept(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e accept: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let conn_id_opt = app.state.active_buffer().map(|b| b.connection_id.clone());
let Some(mgr) = require_mgr(app) else { return };
match mgr.accept_pending_inbound(&handle, &chan) {
Ok(Some(rsp)) => {
if let Some(conn_id) = conn_id_opt {
let ctcp = mgr.encode_keyrsp_ctcp(&rsp);
app.state
.pending_e2e_sends
.push(crate::state::PendingE2eSend {
connection_id: conn_id.clone(),
target: nick.to_string(),
notice_text: ctcp,
});
for out in mgr.take_pending_outbound_keyreqs() {
let ctcp = mgr.encode_keyreq_ctcp(&out.req);
app.state
.pending_e2e_sends
.push(crate::state::PendingE2eSend {
connection_id: conn_id.clone(),
target: nick.to_string(),
notice_text: ctcp,
});
}
} else {
err(app, "/e2e accept: no active connection to send KEYRSP");
return;
}
ok(
app,
&format!("accepted {nick} ({handle}) on {chan} — KEYRSP sent"),
);
return;
}
Ok(None) => {
}
Err(e) => {
err(app, &format!("/e2e accept: {e}"));
return;
}
}
if let Err(e) = mgr
.keyring()
.update_incoming_status(&handle, &chan, TrustStatus::Trusted)
{
err(app, &format!("/e2e accept: {e}"));
return;
}
ok(app, &format!("accepted {nick} ({handle}) on {chan}"));
}
fn e2e_decline(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e decline: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let Some(mgr) = require_mgr(app) else { return };
if let Err(e) = mgr
.keyring()
.update_incoming_status(&handle, &chan, TrustStatus::Revoked)
{
err(app, &format!("/e2e decline: {e}"));
return;
}
warn(app, &format!("declined {nick} on {chan}"));
}
fn e2e_revoke(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e revoke: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let Some(mgr) = require_mgr(app) else { return };
if let Err(e) = mgr
.keyring()
.update_incoming_status(&handle, &chan, TrustStatus::Revoked)
{
err(app, &format!("/e2e revoke: {e}"));
return;
}
if let Err(e) = mgr.keyring().remove_outgoing_recipient(&chan, &handle) {
err(app, &format!("/e2e revoke (drop recipient): {e}"));
return;
}
if let Err(e) = mgr.keyring().mark_outgoing_pending_rotation(&chan) {
err(app, &format!("/e2e revoke (mark rotation): {e}"));
return;
}
warn(
app,
&format!("revoked {nick} on {chan} — key will rotate on next message"),
);
}
fn e2e_unrevoke(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e unrevoke: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let Some(mgr) = require_mgr(app) else { return };
if let Err(e) = mgr
.keyring()
.update_incoming_status(&handle, &chan, TrustStatus::Trusted)
{
err(app, &format!("/e2e unrevoke: {e}"));
return;
}
ok(app, &format!("unrevoked {nick} on {chan}"));
}
fn e2e_forget(app: &mut App, target: &str, all: bool) {
let channel = if all {
current_channel(app)
} else {
let Some(chan) = current_channel(app) else {
err(app, "/e2e forget: no active channel");
return;
};
Some(chan)
};
let Some(active_buffer) = app.state.active_buffer() else {
err(app, "/e2e forget: no active buffer");
return;
};
let conn_id = active_buffer.connection_id.clone();
let buffer_id = active_buffer.id.clone();
if target.contains('@') {
perform_e2e_forget(app, buffer_id, target, target, channel.as_deref(), all);
return;
}
let Some(sender) = app.active_irc_sender().cloned() else {
err(app, "/e2e forget: not connected");
return;
};
if let Err(e) = sender.send(irc::proto::Command::Raw(
"USERHOST".to_string(),
vec![target.to_string()],
)) {
err(app, &format!("/e2e forget: failed to send USERHOST: {e}"));
return;
}
app.state
.pending_userhost_requests
.push(crate::state::PendingUserhostRequest {
connection_id: conn_id,
nick: target.to_string(),
action: crate::state::PendingUserhostAction::E2eForget {
buffer_id,
target: target.to_string(),
channel,
all,
},
});
ok(app, &format!("resolving handle for {target} via USERHOST"));
}
fn perform_e2e_forget(
app: &mut App,
target_buffer: String,
target: &str,
handle: &str,
channel: Option<&str>,
all: bool,
) {
let current_id = app.state.active_buffer_id.clone();
app.state.active_buffer_id = Some(target_buffer);
let Some(mgr) = require_mgr(app) else {
app.state.active_buffer_id = current_id;
return;
};
let result = if all {
mgr.forget_peer_everywhere(handle)
} else {
let Some(channel) = channel else {
err(app, "/e2e forget: no active channel");
app.state.active_buffer_id = current_id;
return;
};
mgr.forget_peer_on_channel(handle, channel)
};
match result {
Ok(deleted) if all => warn(
app,
&format!("forgot {target} ({handle}) globally — removed {deleted} row(s)"),
),
Ok(deleted) => warn(
app,
&format!(
"forgot {target} ({handle}) on {} — removed {deleted} row(s)",
channel.unwrap_or_default()
),
),
Err(e) => err(app, &format!("/e2e forget: {e}")),
}
app.state.active_buffer_id = current_id;
}
fn e2e_handshake(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e handshake: no active channel");
return;
};
let Some(conn_id) = app.state.active_buffer().map(|b| b.connection_id.clone()) else {
err(app, "/e2e handshake: no active connection");
return;
};
let Some(mgr) = require_mgr(app) else { return };
match mgr.build_keyreq(&chan) {
Ok(req) => {
let ctcp = mgr.encode_keyreq_ctcp(&req);
app.state
.pending_e2e_sends
.push(crate::state::PendingE2eSend {
connection_id: conn_id,
target: nick.to_string(),
notice_text: ctcp,
});
ok(app, &format!("KEYREQ sent to {nick} for {chan}"));
}
Err(e) => err(app, &format!("handshake error: {e}")),
}
}
fn e2e_rotate(app: &mut App) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e rotate: no active channel");
return;
};
let Some(mgr) = require_mgr(app) else { return };
if let Err(e) = mgr.keyring().mark_outgoing_pending_rotation(&chan) {
err(app, &format!("/e2e rotate: {e}"));
return;
}
ok(app, &format!("rotation scheduled for {chan}"));
}
fn e2e_list(app: &mut App, all: bool) {
if all {
e2e_list_all(app);
return;
}
let Some(chan) = current_channel(app) else {
err(app, "/e2e list: no active channel");
return;
};
let Some(mgr) = require_mgr(app) else { return };
let peers = match mgr.keyring().list_trusted_peers_for_channel(&chan) {
Ok(p) => p,
Err(e) => {
err(app, &format!("/e2e list: {e}"));
return;
}
};
if peers.is_empty() {
add_local_event(app, ÷r(&format!("E2E Peers on {chan}")));
add_local_event(
app,
&format!(" {C_DIM}(no trusted peers — use /e2e accept <nick>){C_RST}"),
);
return;
}
let mut lines = vec![divider(&format!("E2E Peers on {chan}"))];
for p in &peers {
lines.push(format_peer_line(p));
}
for line in lines {
add_local_event(app, &line);
}
}
fn e2e_list_all(app: &mut App) {
let Some(mgr) = require_mgr(app) else { return };
let peers = match mgr.keyring().list_all_peers() {
Ok(peers) => peers,
Err(e) => {
err(app, &format!("/e2e list -all: {e}"));
return;
}
};
let sessions = match mgr.keyring().list_all_incoming_sessions() {
Ok(sessions) => sessions,
Err(e) => {
err(app, &format!("/e2e list -all: {e}"));
return;
}
};
let mut lines = vec![divider("E2E Keyring (all)")];
if peers.is_empty() && sessions.is_empty() {
lines.push(format!(" {C_DIM}(no remembered E2E state){C_RST}"));
} else {
lines.push(format!(" {C_HEADER}Peers{C_RST}"));
if peers.is_empty() {
lines.push(format!(" {C_DIM}(none){C_RST}"));
} else {
for peer in peers {
let fp_hex = fingerprint_hex(&peer.fingerprint);
let fp_short: String = fp_hex.chars().take(16).collect();
let handle = peer.last_handle.unwrap_or_else(|| "—".to_string());
let nick = peer.last_nick.unwrap_or_else(|| "—".to_string());
lines.push(format!(
" {C_CMD}{handle}{C_RST} {C_TEXT}[{status}]{C_RST} {C_DIM}nick={nick} fp={fp_short}{C_RST}",
status = peer.global_status.as_str(),
));
}
}
lines.push(String::new());
lines.push(format!(" {C_HEADER}Incoming Sessions{C_RST}"));
if sessions.is_empty() {
lines.push(format!(" {C_DIM}(none){C_RST}"));
} else {
for sess in sessions {
let fp_hex = fingerprint_hex(&sess.fingerprint);
let fp_short: String = fp_hex.chars().take(16).collect();
lines.push(format!(
" {C_CMD}{handle}{C_RST} {C_TEXT}{channel}{C_RST} {C_TEXT}[{status}]{C_RST} {C_DIM}fp={fp_short}{C_RST}",
handle = sess.handle,
channel = sess.channel,
status = sess.status.as_str(),
));
}
}
}
for line in lines {
add_local_event(app, &line);
}
}
fn format_peer_line(p: &IncomingSession) -> String {
let fp_hex = fingerprint_hex(&p.fingerprint);
let fp_short: String = fp_hex.chars().take(16).collect();
format!(
" {C_CMD}{handle}{C_RST} {C_TEXT}[{status}]{C_RST} {C_DIM}fp={fp_short}{C_RST}",
handle = p.handle,
status = p.status.as_str(),
)
}
fn e2e_status(app: &mut App) {
let Some(mgr) = require_mgr(app) else { return };
let fp = mgr.fingerprint();
let fp_hex = fingerprint_hex(&fp);
let sas = fingerprint_bip39(&fp).unwrap_or_else(|_| "—".into());
let chan = current_channel(app);
let chan_cfg: Option<ChannelConfig> = chan
.as_ref()
.and_then(|c| mgr.keyring().get_channel_config(c).ok().flatten());
let peer_count = chan
.as_ref()
.and_then(|c| mgr.keyring().list_trusted_peers_for_channel(c).ok())
.map_or(0usize, |v| v.len());
let mut lines = vec![divider("E2E Status")];
lines.push(format!(
" {C_CMD}identity{C_RST} {C_TEXT}{fp_hex}{C_RST}"
));
lines.push(format!(" {C_CMD}sas{C_RST} {C_TEXT}{sas}{C_RST}"));
lines.push(format_status_line(
chan.as_deref(),
chan_cfg.as_ref(),
peer_count,
));
for line in lines {
add_local_event(app, &line);
}
}
fn format_status_line(
chan: Option<&str>,
cfg: Option<&ChannelConfig>,
peer_count: usize,
) -> String {
match (chan, cfg) {
(None, _) => format!(" {C_CMD}channel{C_RST} {C_DIM}(no active channel){C_RST}"),
(Some(c), None) => {
format!(" {C_CMD}channel{C_RST} {C_TEXT}{c}{C_RST} {C_DIM}[off]{C_RST}")
}
(Some(c), Some(cfg)) => {
let state_label = if cfg.enabled { "on" } else { "off" };
format!(
" {C_CMD}channel{C_RST} {C_TEXT}{c}{C_RST} \
{C_DIM}[{state_label}, mode={mode}, peers={peer_count}]{C_RST}",
mode = cfg.mode.as_str(),
)
}
}
}
fn e2e_fingerprint(app: &mut App) {
let Some(mgr) = require_mgr(app) else { return };
let fp = mgr.fingerprint();
let fp_hex = fingerprint_hex(&fp);
let sas = fingerprint_bip39(&fp).unwrap_or_else(|_| "—".into());
let lines = vec![
divider("E2E Fingerprint (mine)"),
format!(" {C_CMD}hex{C_RST} {C_TEXT}{fp_hex}{C_RST}"),
format!(" {C_CMD}sas{C_RST} {C_TEXT}{sas}{C_RST}"),
format!(" {C_DIM}Share these out-of-band so peers can verify your key.{C_RST}"),
];
for line in lines {
add_local_event(app, &line);
}
}
fn e2e_verify(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e verify: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let Some(mgr) = require_mgr(app) else { return };
let local_fp = mgr.fingerprint();
match mgr.keyring().get_incoming_session(&handle, &chan) {
Ok(Some(sess)) => {
let lines = format_verify_block(&local_fp, &sess.fingerprint, nick, &handle);
for line in lines {
add_local_event(app, &line);
}
}
Ok(None) => err(app, &format!("no session for {nick} on {chan}")),
Err(e) => err(app, &format!("/e2e verify: {e}")),
}
}
fn format_verify_block(
local_fp: &crate::e2e::crypto::fingerprint::Fingerprint,
peer_fp: &crate::e2e::crypto::fingerprint::Fingerprint,
peer_nick: &str,
peer_handle: &str,
) -> Vec<String> {
let local_hex = fingerprint_hex(local_fp);
let local_short: String = local_hex.chars().take(16).collect();
let local_sas = fingerprint_bip39(local_fp).unwrap_or_else(|_| "—".into());
let peer_hex = fingerprint_hex(peer_fp);
let peer_short: String = peer_hex.chars().take(16).collect();
let peer_sas = fingerprint_bip39(peer_fp).unwrap_or_else(|_| "—".into());
vec![
divider("E2E Fingerprint Verification"),
format!(
" {C_CMD}You ( local){C_RST}: {C_TEXT}{local_short}{C_RST} {C_TEXT}{local_sas}{C_RST}"
),
format!(
" {C_CMD}Them ({peer_nick:<7}){C_RST}: {C_TEXT}{peer_short}{C_RST} {C_TEXT}{peer_sas}{C_RST}"
),
format!(" {C_DIM}peer handle: {peer_handle}{C_RST}"),
String::new(),
format!(" {C_DIM}Read both lines out-of-band (phone, signal, etc.) and confirm{C_RST}"),
format!(" {C_DIM}they match BEFORE trusting future messages. If they differ,{C_RST}"),
format!(
" {C_ERR}a MitM is in progress{C_RST}{C_DIM} — run {C_CMD}/e2e forget {peer_nick}{C_DIM} immediately.{C_RST}"
),
]
}
fn e2e_reverify(app: &mut App, nick: &str) {
let Some(chan) = current_channel(app) else {
err(app, "/e2e reverify: no active channel");
return;
};
let Some(handle) = require_handle_for_nick(app, &chan, nick) else {
return;
};
let Some(mgr) = require_mgr(app) else { return };
match mgr.reverify_peer(&handle) {
Ok(crate::e2e::manager::ReverifyOutcome::Applied { old_fp, new_fp }) => {
ok(
app,
&format!(
"reverified {nick}: old fp={} → new fp={} — installed new key",
fingerprint_hex(&old_fp),
fingerprint_hex(&new_fp),
),
);
}
Ok(crate::e2e::manager::ReverifyOutcome::Cleared { deleted }) => {
ok(
app,
&format!(
"reverified {nick}: purged {deleted} stale row(s); \
re-handshake to TOFU-pin the new key"
),
);
}
Ok(crate::e2e::manager::ReverifyOutcome::NotFound) => {
err(
app,
&format!("no keyring state for {nick} ({handle}) to reverify"),
);
}
Err(e) => err(app, &format!("/e2e reverify: {e}")),
}
}
fn e2e_autotrust(app: &mut App, op: AutotrustOp) {
let Some(mgr) = require_mgr(app) else { return };
match op {
AutotrustOp::List => match mgr.keyring().list_autotrust() {
Ok(rows) if rows.is_empty() => {
add_local_event(app, ÷r("E2E Autotrust Rules"));
add_local_event(app, &format!(" {C_DIM}(no rules){C_RST}"));
}
Ok(rows) => {
let mut lines = vec![divider("E2E Autotrust Rules")];
for (scope, pat) in rows {
lines.push(format!(" {C_CMD}{scope}{C_RST} {C_TEXT}{pat}{C_RST}"));
}
for line in lines {
add_local_event(app, &line);
}
}
Err(e) => err(app, &format!("/e2e autotrust list: {e}")),
},
AutotrustOp::Add(scope, pat) => {
let now = chrono::Utc::now().timestamp();
if let Err(e) = mgr.keyring().add_autotrust(&scope, &pat, now) {
err(app, &format!("/e2e autotrust add: {e}"));
} else {
ok(app, &format!("autotrust add {scope} {pat}"));
}
}
AutotrustOp::Remove(pat) => {
if let Err(e) = mgr.keyring().remove_autotrust(&pat) {
err(app, &format!("/e2e autotrust remove: {e}"));
} else {
ok(app, &format!("autotrust removed {pat}"));
}
}
AutotrustOp::Usage(hint) => err(app, &format!("usage: {hint}")),
}
}
fn e2e_export(app: &mut App, path: Option<&str>) {
let Some(raw_path) = path else {
err(app, "/e2e export: usage: /e2e export <file>");
return;
};
let resolved = match crate::e2e::portable::expand_path(raw_path) {
Ok(p) => p,
Err(e) => {
err(app, &format!("/e2e export: {e}"));
return;
}
};
let Some(mgr) = require_mgr(app) else { return };
match crate::e2e::portable::export_to_path(mgr.keyring(), &resolved) {
Ok(summary) => {
let sessions = summary.incoming + summary.outgoing;
ok(
app,
&format!(
"exported keyring to {} (identity + {} peers + {} sessions)",
resolved.display(),
summary.peers,
sessions,
),
);
add_local_event(
app,
&format!(
" {C_DIM}warning: session keys are in plaintext in this file. \
Protect it with filesystem ACLs; never share or commit it.{C_RST}"
),
);
}
Err(e) => err(app, &format!("/e2e export: {e}")),
}
}
fn e2e_import(app: &mut App, path: Option<&str>) {
let Some(raw_path) = path else {
err(app, "/e2e import: usage: /e2e import <file>");
return;
};
let resolved = match crate::e2e::portable::expand_path(raw_path) {
Ok(p) => p,
Err(e) => {
err(app, &format!("/e2e import: {e}"));
return;
}
};
let Some(mgr) = require_mgr(app) else { return };
match crate::e2e::portable::import_from_path(mgr.keyring(), &resolved) {
Ok(summary) => {
ok(
app,
&format!(
"imported keyring from {} (identity={}, peers={}, incoming={}, \
outgoing={}, channels={}, autotrust={})",
resolved.display(),
summary.identity,
summary.peers,
summary.incoming,
summary.outgoing,
summary.channels,
summary.autotrust,
),
);
}
Err(e) => err(app, &format!("/e2e import: {e}")),
}
}
const HELP_ENTRIES: &[(&str, &str)] = &[
("on", "Enable E2E on the current channel"),
("off", "Disable E2E on the current channel"),
("mode <m>", "Set channel mode (auto-accept|normal|quiet)"),
(
"handshake <nick>",
"Send KEYREQ to <nick> (manual key exchange)",
),
("accept <nick>", "Trust a pending peer on this channel"),
("decline <nick>", "Reject a pending peer"),
(
"revoke <nick>",
"Revoke trust; rotate outgoing key next send",
),
("unrevoke <nick>", "Re-trust a previously revoked peer"),
(
"forget [-all] <nick|handle>",
"Delete channel or global peer state",
),
("verify <nick>", "Show a peer's fingerprint + SAS words"),
("reverify <nick>", "Re-trust after SAS comparison"),
("rotate", "Schedule outgoing key rotation for this channel"),
(
"list [-all]",
"List trusted peers or the full remembered state",
),
("status", "Show identity + per-channel summary"),
("fingerprint", "Show my own fingerprint + SAS words"),
("autotrust list", "List autotrust rules"),
("autotrust add <scope> <pat>", "Add an autotrust rule"),
("autotrust remove <pat>", "Remove an autotrust rule"),
(
"export <file>",
"Export keyring to a JSON file (plaintext keys, 0600)",
),
("import <file>", "Import keyring from a JSON file"),
("help", "Show this index"),
];
fn e2e_help(app: &mut App) {
let mut lines = vec![divider("E2E Encryption")];
let name_width = HELP_ENTRIES.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (name, desc) in HELP_ENTRIES {
lines.push(format!(
" {C_CMD}{name:<name_width$}{C_RST} {C_DIM}{desc}{C_RST}"
));
}
lines.push(format!(
"{C_HEADER}────────────────────────────────────────────{C_RST}"
));
for line in lines {
add_local_event(app, &line);
}
}
fn resolve_handle_by_nick(app: &App, channel: &str, nick: &str) -> Option<String> {
use crate::state::buffer::make_buffer_id;
let conn_id = app.state.active_buffer()?.connection_id.clone();
let buf_id = make_buffer_id(&conn_id, channel);
let buf = app.state.buffers.get(&buf_id)?;
let entry = buf.users.get(&nick.to_lowercase())?;
let ident = entry.ident.as_deref().unwrap_or("");
let host = entry.host.as_deref().unwrap_or("");
if ident.is_empty() && host.is_empty() {
None
} else {
Some(format!("{ident}@{host}"))
}
}
fn resolve_cached_handle_by_nick(
app: &App,
nick: &str,
) -> Option<std::result::Result<String, crate::e2e::error::E2eError>> {
let mgr = app.state.e2e_manager.as_ref()?;
let mut matches = match mgr.keyring().list_all_peers() {
Ok(peers) => peers
.into_iter()
.filter_map(|peer| match (peer.last_nick, peer.last_handle) {
(Some(last_nick), Some(last_handle)) if last_nick.eq_ignore_ascii_case(nick) => {
Some((peer.last_seen, last_handle))
}
_ => None,
})
.collect::<Vec<_>>(),
Err(e) => return Some(Err(e)),
};
matches.sort_by_key(|(last_seen, _)| *last_seen);
matches.pop().map(|(_, handle)| Ok(handle))
}
fn require_handle_for_nick(app: &mut App, channel: &str, nick: &str) -> Option<String> {
if let Some(handle) = resolve_handle_by_nick(app, channel, nick) {
return Some(handle);
}
match resolve_cached_handle_by_nick(app, nick) {
Some(Ok(handle)) => Some(handle),
Some(Err(e)) => {
err(app, &format!("cannot resolve handle for {nick}: {e}"));
None
}
None => {
err(
app,
&format!("cannot resolve handle for {nick} — has the user spoken yet?"),
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::e2e::keyring::{ChannelConfig, ChannelMode, IncomingSession, TrustStatus};
fn s(x: &str) -> String {
x.to_string()
}
#[test]
fn test_subcommand_dispatch_case_insensitive() {
assert_eq!(parse_subcommand(&[s("on")]), E2eSub::On);
assert_eq!(parse_subcommand(&[s("ON")]), E2eSub::On);
assert_eq!(parse_subcommand(&[s("On")]), E2eSub::On);
assert_eq!(parse_subcommand(&[s("oN")]), E2eSub::On);
assert_eq!(parse_subcommand(&[s("off")]), E2eSub::Off);
assert_eq!(parse_subcommand(&[s("OFF")]), E2eSub::Off);
assert_eq!(parse_subcommand(&[s("LIST")]), E2eSub::List { all: false });
assert_eq!(parse_subcommand(&[s("Status")]), E2eSub::Status);
assert_eq!(parse_subcommand(&[s("FingerPrint")]), E2eSub::Fingerprint);
assert_eq!(parse_subcommand(&[s("Rotate")]), E2eSub::Rotate);
assert_eq!(parse_subcommand(&[s("HELP")]), E2eSub::Help);
assert_eq!(parse_subcommand(&[s("?")]), E2eSub::Help);
}
#[test]
fn test_subcommand_dispatch_accept_carries_nick_verbatim() {
assert_eq!(
parse_subcommand(&[s("ACCEPT"), s("Alice")]),
E2eSub::Accept(s("Alice"))
);
assert_eq!(
parse_subcommand(&[s("verify"), s("BoB")]),
E2eSub::Verify(s("BoB"))
);
}
#[test]
fn test_subcommand_dispatch_forget_all_accepts_both_flag_positions() {
assert_eq!(
parse_subcommand(&[s("forget"), s("-all"), s("k2")]),
E2eSub::Forget {
target: s("k2"),
all: true,
}
);
assert_eq!(
parse_subcommand(&[s("forget"), s("k2"), s("-all")]),
E2eSub::Forget {
target: s("k2"),
all: true,
}
);
assert_eq!(
parse_subcommand(&[s("list"), s("-all")]),
E2eSub::List { all: true }
);
}
#[test]
fn test_subcommand_dispatch_missing_nick_is_usage() {
assert!(matches!(parse_subcommand(&[s("accept")]), E2eSub::Usage(_)));
assert!(matches!(parse_subcommand(&[s("verify")]), E2eSub::Usage(_)));
assert!(matches!(
parse_subcommand(&[s("handshake")]),
E2eSub::Usage(_)
));
}
#[test]
fn test_subcommand_dispatch_unknown() {
match parse_subcommand(&[s("wombat")]) {
E2eSub::Unknown(tok) => assert_eq!(tok, "wombat"),
other => panic!("expected Unknown, got {other:?}"),
}
match parse_subcommand(&[s("NOPE")]) {
E2eSub::Unknown(tok) => assert_eq!(tok, "nope"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn test_subcommand_dispatch_empty_is_none() {
assert_eq!(parse_subcommand(&[]), E2eSub::None);
}
#[test]
fn test_mode_parse_valid() {
assert_eq!(parse_mode("auto-accept").unwrap(), ChannelMode::AutoAccept);
assert_eq!(parse_mode("auto").unwrap(), ChannelMode::AutoAccept);
assert_eq!(parse_mode("normal").unwrap(), ChannelMode::Normal);
assert_eq!(parse_mode("quiet").unwrap(), ChannelMode::Quiet);
}
#[test]
fn test_mode_parse_case_insensitive() {
assert_eq!(parse_mode("AUTO-ACCEPT").unwrap(), ChannelMode::AutoAccept);
assert_eq!(parse_mode("Normal").unwrap(), ChannelMode::Normal);
assert_eq!(parse_mode("QUIET").unwrap(), ChannelMode::Quiet);
}
#[test]
fn test_mode_parse_invalid() {
let err = parse_mode("garbage").unwrap_err();
assert!(err.contains("garbage"));
assert!(err.contains("auto-accept"));
assert!(err.contains("normal"));
assert!(err.contains("quiet"));
}
#[test]
fn test_autotrust_op_list() {
assert_eq!(
parse_subcommand(&[s("autotrust"), s("list")]),
E2eSub::Autotrust(AutotrustOp::List)
);
assert_eq!(
parse_subcommand(&[s("AUTOTRUST"), s("LIST")]),
E2eSub::Autotrust(AutotrustOp::List)
);
}
#[test]
fn test_autotrust_op_add_requires_both_args() {
assert_eq!(
parse_subcommand(&[s("autotrust"), s("add"), s("channel"), s("*!*@evil")]),
E2eSub::Autotrust(AutotrustOp::Add(s("channel"), s("*!*@evil")))
);
match parse_subcommand(&[s("autotrust"), s("add")]) {
E2eSub::Autotrust(AutotrustOp::Usage(_)) => {}
other => panic!("expected Usage, got {other:?}"),
}
match parse_subcommand(&[s("autotrust"), s("add"), s("channel")]) {
E2eSub::Autotrust(AutotrustOp::Usage(_)) => {}
other => panic!("expected Usage, got {other:?}"),
}
}
#[test]
fn test_autotrust_op_remove() {
assert_eq!(
parse_subcommand(&[s("autotrust"), s("remove"), s("pat")]),
E2eSub::Autotrust(AutotrustOp::Remove(s("pat")))
);
match parse_subcommand(&[s("autotrust"), s("remove")]) {
E2eSub::Autotrust(AutotrustOp::Usage(_)) => {}
other => panic!("expected Usage, got {other:?}"),
}
}
#[test]
fn test_autotrust_op_no_op_is_usage() {
match parse_subcommand(&[s("autotrust")]) {
E2eSub::Autotrust(AutotrustOp::Usage(_)) => {}
other => panic!("expected Usage, got {other:?}"),
}
}
#[test]
fn test_export_import_optional_path() {
assert_eq!(parse_subcommand(&[s("export")]), E2eSub::Export(None));
assert_eq!(
parse_subcommand(&[s("export"), s("/tmp/out.json")]),
E2eSub::Export(Some(s("/tmp/out.json")))
);
assert_eq!(parse_subcommand(&[s("import")]), E2eSub::Import(None));
assert_eq!(
parse_subcommand(&[s("IMPORT"), s("/tmp/in.json")]),
E2eSub::Import(Some(s("/tmp/in.json")))
);
}
fn strict_resolve(resolved: Option<String>) -> Result<String, &'static str> {
resolved.ok_or("cannot resolve handle — has the user spoken yet?")
}
#[test]
fn test_strict_resolve_some_passthrough() {
assert_eq!(
strict_resolve(Some(s("~alice@host.example"))).unwrap(),
"~alice@host.example"
);
}
#[test]
fn test_strict_resolve_none_is_error_not_nick_fallback() {
match strict_resolve(None) {
Err(msg) => assert!(msg.contains("has the user spoken yet?")),
Ok(s) => panic!("expected Err, got Ok({s})"),
}
}
#[test]
fn test_strict_resolve_none_for_multiple_nicks() {
assert!(strict_resolve(None).is_err());
assert!(strict_resolve(None).is_err());
}
#[test]
fn test_format_status_line_no_channel() {
let line = format_status_line(None, None, 0);
assert!(line.contains("no active channel"));
assert!(line.contains("channel"));
}
#[test]
fn test_format_status_line_no_config() {
let line = format_status_line(Some("#rust"), None, 0);
assert!(line.contains("#rust"));
assert!(line.contains("off"));
}
#[test]
fn test_format_status_line_enabled() {
let cfg = ChannelConfig {
channel: s("#rust"),
enabled: true,
mode: ChannelMode::Normal,
};
let line = format_status_line(Some("#rust"), Some(&cfg), 3);
assert!(line.contains("#rust"));
assert!(line.contains("on"));
assert!(line.contains("mode=normal"));
assert!(line.contains("peers=3"));
}
#[test]
fn test_format_status_line_disabled_explicit() {
let cfg = ChannelConfig {
channel: s("#rust"),
enabled: false,
mode: ChannelMode::AutoAccept,
};
let line = format_status_line(Some("#rust"), Some(&cfg), 0);
assert!(line.contains("#rust"));
assert!(line.contains("off"));
assert!(line.contains("mode=auto-accept"));
}
#[test]
fn test_format_peer_line_truncates_fp() {
let sess = IncomingSession {
handle: s("~alice@host.example"),
channel: s("#rust"),
fingerprint: [
0xde, 0xad, 0xbe, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xed,
0xfa, 0xce,
],
sk: [0u8; 32],
status: TrustStatus::Trusted,
created_at: 0,
};
let line = format_peer_line(&sess);
assert!(line.contains("~alice@host.example"));
assert!(line.contains("trusted"));
assert!(line.contains("deadbeef"));
assert!(line.contains("fp=deadbeef"));
assert!(!line.contains("feedface"));
}
#[test]
fn test_e2e_event_level_maps_to_theme_key() {
assert_eq!(E2eEventLevel::Info.event_key(), "e2e_info");
assert_eq!(E2eEventLevel::Warning.event_key(), "e2e_warning");
assert_eq!(E2eEventLevel::Error.event_key(), "e2e_error");
}
#[test]
fn test_e2e_event_level_error_highlights() {
assert!(E2eEventLevel::Error == E2eEventLevel::Error);
assert_ne!(E2eEventLevel::Info, E2eEventLevel::Error);
assert_ne!(E2eEventLevel::Warning, E2eEventLevel::Error);
}
#[test]
fn test_e2e_event_builds_message_with_event_key_and_params() {
let id: u64 = 1;
let text = "accepted bob on #rust";
let level = E2eEventLevel::Info;
let msg = Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.to_string(),
highlight: level == E2eEventLevel::Error,
event_key: Some(level.event_key().to_string()),
event_params: Some(vec![text.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags: None,
};
assert_eq!(msg.event_key.as_deref(), Some("e2e_info"));
assert_eq!(
msg.event_params.as_deref(),
Some([text.to_string()].as_slice()),
"event_params[0] must carry the message text for the theme's $*"
);
assert!(!msg.highlight, "info-level events must not highlight");
}
#[test]
fn test_format_verify_block_renders_both_sides() {
let local_fp: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff, 0x00,
];
let peer_fp: [u8; 16] = [
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99,
];
let lines = format_verify_block(&local_fp, &peer_fp, "bob", "~bob@b.host");
assert!(!lines.is_empty(), "verify block must render lines");
let joined = lines.join("\n");
assert!(
joined.contains("1122334455667788"),
"local hex must be rendered: {joined}"
);
assert!(
joined.contains("aabbccddeeff0011"),
"peer hex must be rendered: {joined}"
);
assert!(joined.contains("You"), "must label local as 'You'");
assert!(joined.contains("Them"), "must label peer as 'Them'");
assert!(
joined.contains("MitM"),
"must include the MitM warning: {joined}"
);
assert!(
joined.contains("~bob@b.host"),
"peer handle must be rendered: {joined}"
);
let local_sas = fingerprint_bip39(&local_fp).unwrap();
let peer_sas = fingerprint_bip39(&peer_fp).unwrap();
assert!(
joined.contains(&local_sas),
"local SAS words must appear in block"
);
assert!(
joined.contains(&peer_sas),
"peer SAS words must appear in block"
);
}
#[test]
fn test_help_entries_nonempty_and_unique_names() {
assert!(!HELP_ENTRIES.is_empty());
let mut names: Vec<&str> = HELP_ENTRIES.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "HELP_ENTRIES must have unique names");
for (name, desc) in HELP_ENTRIES {
assert!(!name.is_empty(), "help entry name empty");
assert!(!desc.is_empty(), "help entry desc empty");
}
}
}