feat: gate first contact on a pinned server, confirm key changes

This commit is contained in:
randogoth 2026-10-08 21:36:02 +03:00
parent 743c388cfb
commit 4c5665a6ff
5 changed files with 981 additions and 44 deletions

View file

@ -11,6 +11,7 @@
use std::sync::atomic::Ordering;
use std::time::Duration;
use fumi_core::address::trust_label;
use fumi_core::crypto::{b32, fingerprint, KEY_LEN};
use gtk::prelude::*;
use gtk::{Align, Orientation, WrapMode};
@ -20,7 +21,8 @@ use relm4::ComponentSender;
use relm4::{Component, ComponentParts};
use crate::app::{
Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep,
Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode,
SignupStep,
};
use crate::ops::{self, Contact, Folder, Row};
use relm4::RelmApp;
@ -210,6 +212,13 @@ pub struct CarrierWidgets {
compose_subject: adw::EntryRow,
compose_body: gtk::TextBuffer,
compose_send: adw::ButtonContent,
compose_pin_card: gtk::Box,
compose_pin_title: gtk::Label,
compose_pin_body: gtk::Label,
compose_pin_key: gtk::Label,
compose_pin_fingerprint: gtk::Label,
compose_pin_hint: gtk::Label,
compose_pin_button: gtk::Button,
// sidebar bottom: own identity and the contact list
sidebar_own: gtk::Label,
@ -228,6 +237,7 @@ pub struct CarrierWidgets {
public_key_row: adw::ActionRow,
rotations_value: gtk::Label,
keep_mail_check: gtk::CheckButton,
strict_check: gtk::CheckButton,
timeout_row: adw::SpinRow,
// onboarding
@ -569,6 +579,56 @@ impl CarrierWidgets {
compose_fields.append(&compose_to);
compose_fields.append(&compose_subject);
compose.append(&compose_fields);
// Shown when a send was refused because the recipient's key would
// have come from a server with no pin: the refusal and its fix in
// one place, since nowhere else in the app pins a correspondent's
// server.
let compose_pin_title = label("", "heading");
compose_pin_title.set_wrap(true);
let compose_pin_body = label("", "dim");
compose_pin_body.set_wrap(true);
let compose_pin_key = label("", "mono");
compose_pin_key.set_selectable(true);
compose_pin_key.set_wrap(true);
compose_pin_key.set_wrap_mode(gtk::pango::WrapMode::Char);
let compose_pin_fingerprint = label("", "mono dim");
compose_pin_fingerprint.set_selectable(true);
compose_pin_fingerprint.set_wrap(true);
let compose_pin_hint = label(
"If the server operator published a key or fingerprint, make \
sure it matches before trusting it.",
"dim caption",
);
compose_pin_hint.set_wrap(true);
let compose_pin_button = button("", "accent");
compose_pin_button.set_halign(Align::Start);
compose_pin_button.set_margin_top(6);
let compose_pin_card = gtk::Box::new(Orientation::Vertical, 6);
compose_pin_card.add_css_class("card");
compose_pin_card.set_visible(false);
for child in [&compose_pin_title, &compose_pin_body] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_title.set_margin_top(12);
for child in [
&compose_pin_key,
&compose_pin_fingerprint,
&compose_pin_hint,
] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_button.set_margin_start(12);
compose_pin_button.set_margin_bottom(12);
compose_pin_card.append(&compose_pin_title);
compose_pin_card.append(&compose_pin_body);
compose_pin_card.append(&compose_pin_key);
compose_pin_card.append(&compose_pin_fingerprint);
compose_pin_card.append(&compose_pin_hint);
compose_pin_card.append(&compose_pin_button);
compose.append(&compose_pin_card);
// Frontmatter rows: one `Key: value` entry each, plus a flat
// add button below. Rows are rebuilt in render on structural
// changes only, so the entries never lose focus while typing.
@ -770,6 +830,32 @@ impl CarrierWidgets {
rotations_group.add(&rotate_button);
advanced_page.add(&rotations_group);
let trust_group = adw::PreferencesGroup::builder()
.title("First Contact")
.description(
"A recipient who is not a contact yet, and whose address carries \
no key, has to be looked up on their own server. That answer is \
only as good as the server: one with no pinned key can return a \
key of its own and read what you send.",
)
.build();
let strict_row = adw::ActionRow::builder()
.title("Require a pinned server")
.subtitle(
"Stop such a send and offer to pin the server's key first, so it \
can be checked against what the operator published. With this \
off, the key is taken on trust and the send is only reported as \
unverified afterwards.",
)
.subtitle_lines(0)
.activatable(true)
.build();
let strict_check = gtk::CheckButton::builder().valign(Align::Center).build();
strict_row.add_suffix(&strict_check);
strict_row.set_activatable_widget(Some(&strict_check));
trust_group.add(&strict_row);
advanced_page.add(&trust_group);
// The log-out button is wide and destructive, so it sits in the
// group's body under the warning rather than squeezed beside the
// header where its label would crowd the title.
@ -1086,6 +1172,7 @@ impl CarrierWidgets {
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
compose_send_button.connect_clicked(input(Message::SendPressed));
compose_pin_button.connect_clicked(input(Message::PinServerAction));
compose_cancel.connect_clicked(input(Message::ComposeClosed));
let to_sender = sender.clone();
compose_to.connect_changed(move |entry| {
@ -1123,6 +1210,10 @@ impl CarrierWidgets {
keep_mail_check.connect_toggled(move |check| {
keep_mail_sender.input(Message::KeepMailChanged(check.is_active()))
});
let strict_sender = sender.clone();
strict_check.connect_toggled(move |check| {
strict_sender.input(Message::StrictPinningChanged(check.is_active()))
});
// Export/Import go straight to a native file dialog rather than a
// Message — the chosen path only matters for this one call, and
// round-tripping it through the model would gain nothing.
@ -1238,6 +1329,13 @@ impl CarrierWidgets {
compose_subject,
compose_body,
compose_send,
compose_pin_card,
compose_pin_title,
compose_pin_body,
compose_pin_key,
compose_pin_fingerprint,
compose_pin_hint,
compose_pin_button,
sidebar_own,
sidebar_contacts: contacts_list,
detail_share,
@ -1250,6 +1348,7 @@ impl CarrierWidgets {
public_key_row,
rotations_value,
keep_mail_check,
strict_check,
timeout_row,
onboard_seed_label,
onboard_seed_entry,
@ -1354,8 +1453,8 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::LearnKey { addr } => sender.spawn_command(move |out| {
let offered =
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)));
let offered = ops::learn_key(&store, &addr, timeout)
.map(|key| key.map(|key| (addr.clone(), key)));
let _ = out.send(OpResult::PinOffered(offered));
}),
PendingOp::Pin { addr, key } => sender.spawn_command(move |out| {
@ -1496,6 +1595,46 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
}
}
// The pin card follows the recipient: a prompt armed for a different
// address simply isn't shown, so editing To retires it with no
// explicit clearing.
let compose_prompt = model.compose.as_ref().and_then(|compose| {
let typed = ops::parse_address(compose.to.trim()).ok()?.short();
model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == typed)
});
w.compose_pin_card.set_visible(compose_prompt.is_some());
if let Some(prompt) = compose_prompt {
w.compose_pin_title.set_text(&format!(
"{} is not pinned",
trust_label(&prompt.addr.host, prompt.addr.port)
));
w.compose_pin_body.set_text(&format!(
"{} is not a contact yet, so carrier would have to ask this \
server for their key — and a server with no pinned key can \
answer with one of its own and read the message.",
prompt.addr.short()
));
match prompt.offered {
Some(key) => {
w.compose_pin_key.set_text(&b32(&key));
w.compose_pin_fingerprint.set_text(&fingerprint(&key));
w.compose_pin_button.set_label("Pin and send");
}
None => w.compose_pin_button.set_label("Fetch their server's key"),
}
let fetched = prompt.offered.is_some();
for line in [
&w.compose_pin_key,
&w.compose_pin_fingerprint,
&w.compose_pin_hint,
] {
line.set_visible(fetched);
}
}
// Header and sidebar identity.
let address = model
.snapshot
@ -1642,12 +1781,37 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&contact.address);
w.article_meta.set_text(contact_state(contact));
w.article_body.set_text(&detail_body(
&contact.address,
&contact.key,
&contact.fingerprint,
// `contact_state` collapses to accepted/blocked once a token
// exists, which says nothing about where the key came from —
// so the server's pin state is spelled out beside it.
w.article_meta.set_text(&format!(
"{} · server {}",
contact_state(contact),
if contact.pinned {
"pinned"
} else {
"not pinned"
}
));
let mut body = detail_body(&contact.address, &contact.key, &contact.fingerprint);
if !contact.server.is_empty() {
body.push_str(&format!("\nserver {}", contact.server));
}
// A key fetched for this contact's server renders here rather
// than in a second card: the pane is already a key column.
if let Some(key) = model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == contact.address)
.and_then(|prompt| prompt.offered)
{
body.push_str(&format!(
"\n\nthis server presents\nkey {}\nfingerprint {}\n\nIf the server operator published a key or fingerprint, make sure it matches before trusting it.",
b32(&key),
fingerprint(&key)
));
}
w.article_body.set_text(&body);
set_qr(
&w.detail_qr,
&format!("smol://{}/{}", contact.address, contact.key),
@ -1664,6 +1828,22 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
let spacer = gtk::Label::new(None);
spacer.set_hexpand(true);
w.actions.append(&spacer);
if !contact.pinned && !contact.server.is_empty() {
let fetched = model.pin_prompt.as_ref().is_some_and(|prompt| {
prompt.offered.is_some() && prompt.addr.short() == contact.address
});
let pin = icon_label_button(
"channel-secure-symbolic",
if fetched {
"Pin this key"
} else {
"Pin server"
},
if fetched { "accent" } else { "" },
);
pin.connect_clicked(input(Message::PinServerAction));
w.actions.append(&pin);
}
match contact.accepted {
Some(true) => {
let block = icon_label_button("action-unavailable-symbolic", "Block", "danger");
@ -1821,9 +2001,14 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
row.set_subtitle(&text);
}
w.host_row.set_subtitle(&snap.who.host);
// Pins are ordered by host and hold correspondents' servers too,
// so the account's own is found by its label rather than taken
// off the front of the list.
let own = trust_label(&snap.who.host, snap.who.port);
let public_key = snap
.pins
.first()
.iter()
.find(|(label, _)| *label == own)
.map(|(_, key)| b32(key))
.unwrap_or_else(|| "not pinned yet".to_string());
w.public_key_row.set_subtitle(&public_key);
@ -1847,6 +2032,9 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
if w.keep_mail_check.is_active() != model.keep_mail {
w.keep_mail_check.set_active(model.keep_mail);
}
if w.strict_check.is_active() != model.strict_pinning {
w.strict_check.set_active(model.strict_pinning);
}
if w.timeout_row.value() as u64 != model.timeout {
w.timeout_row.set_value(model.timeout as f64);
}
@ -1911,9 +2099,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
// again would stack a second copy over the first.
match (&model.dialog, w.dialog.is_some()) {
(Some(dialog), false) => {
let rotating = matches!(dialog, Dialog::Rotate);
let replying = matches!(dialog, Dialog::Reply { .. });
let resetting = matches!(dialog, Dialog::Reset);
let kind = dialog.kind();
let alert = match dialog {
Dialog::Delete { kept, .. } => {
let alert = adw::AlertDialog::new(
@ -1974,25 +2160,50 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
alert
}
Dialog::KeyChange {
address,
known_fingerprint,
offered_fingerprint,
..
} => {
let alert = adw::AlertDialog::new(
Some("Replace this contact's key?"),
Some(&format!(
"{address} is already on file with a different key.\n\nknown {known_fingerprint}\nnew {offered_fingerprint}\n\nThe address you were given carries the new key and would replace the stored one. If this contact did not hand it to you themselves, stop: a key swapped in on the way reads everything you send them."
)),
);
alert.add_response("cancel", "Cancel");
alert.add_response("replace", "Replace Key");
alert.set_response_appearance("replace", adw::ResponseAppearance::Destructive);
alert
}
};
// The reply dialog carries its own default: Enter answers the
// question instead of cancelling the reply the user asked for.
let close_default = if replying { "without" } else { "cancel" };
let close_default = if kind == DialogKind::Reply {
"without"
} else {
"cancel"
};
alert.set_close_response("cancel");
alert.set_default_response(Some(close_default));
let response_sender = sender.clone();
alert.connect_response(None, move |_, response| {
response_sender.input(match (rotating, replying, resetting, response) {
(true, _, _, "rotate") => Message::RotateConfirmed,
(true, _, _, _) => Message::RotateCancelled,
(_, true, _, "copy") => Message::ReplyConfirmed(true),
(_, true, _, "without") => Message::ReplyConfirmed(false),
(_, true, _, _) => Message::ReplyCancelled,
(_, _, true, "reset") => Message::ResetConfirmed,
(_, _, true, _) => Message::ResetCancelled,
(false, _, _, "here") => Message::DeleteConfirmed(false),
(false, _, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _, _) => Message::DeleteCancelled,
// Anything that is not an explicit confirmation — a
// Cancel, Escape, or the close response — cancels.
response_sender.input(match (kind, response) {
(DialogKind::Delete, "here") => Message::DeleteConfirmed(false),
(DialogKind::Delete, "everywhere") => Message::DeleteConfirmed(true),
(DialogKind::Delete, _) => Message::DeleteCancelled,
(DialogKind::Rotate, "rotate") => Message::RotateConfirmed,
(DialogKind::Rotate, _) => Message::RotateCancelled,
(DialogKind::Reset, "reset") => Message::ResetConfirmed,
(DialogKind::Reset, _) => Message::ResetCancelled,
(DialogKind::Reply, "copy") => Message::ReplyConfirmed(true),
(DialogKind::Reply, "without") => Message::ReplyConfirmed(false),
(DialogKind::Reply, _) => Message::ReplyCancelled,
(DialogKind::KeyChange, "replace") => Message::KeyChangeConfirmed,
(DialogKind::KeyChange, _) => Message::KeyChangeCancelled,
});
});
alert.present(Some(&w.window));