feat: gate first contact on a pinned server, confirm key changes
This commit is contained in:
parent
743c388cfb
commit
4c5665a6ff
5 changed files with 981 additions and 44 deletions
259
src/view.rs
259
src/view.rs
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue