diff --git a/crates/protocol/src/e2ee.rs b/crates/protocol/src/e2ee.rs index 6f88a477..f09dcf97 100644 --- a/crates/protocol/src/e2ee.rs +++ b/crates/protocol/src/e2ee.rs @@ -82,6 +82,35 @@ pub fn grouped_safety(digits: &str) -> String { .join(" ") } +/// Concrete glyphs for a digit code (call code or safety number), Telegram-style: no skin +/// tones, no lookalike pairs. Same digits always map to the same four. +const SAFETY_EMOJIS: &[&str] = &[ + "๐ŸฆŠ", "๐ŸŽฉ", "๐ŸŒ™", "๐ŸŽธ", "๐ŸŽ", "โšก", "๐ŸŽฏ", "๐ŸŒต", "๐ŸŽฒ", "๐ŸŒˆ", "๐Ÿš€", "๐Ÿ€", "๐ŸŽง", "๐Ÿฆ„", "๐Ÿ•", "๐Ÿ”‘", + "๐ŸŒŠ", "๐ŸŽจ", "๐Ÿธ", "๐Ÿฉ", "๐Ÿ”ฅ", "๐ŸŽˆ", "๐Ÿฃ", "๐ŸŒน", "๐Ÿฏ", "๐ŸŽต", "๐ŸŒป", "๐Ÿช", "๐Ÿซ", "๐Ÿชผ", "๐Ÿ‰", "๐Ÿงฟ", + "๐Ÿšฒ", "๐Ÿ‹", "๐Ÿฆ‹", "๐Ÿ„", "๐Ÿช", "๐Ÿ™", "๐ŸŒฝ", "๐Ÿ’Ž", "๐Ÿฆ‰", "๐Ÿฅ", "๐Ÿงฒ", "๐Ÿ", "๐Ÿ‡", "๐Ÿงญ", "๐Ÿฆœ", "๐Ÿ“", + "๐Ÿฅ", "๐Ÿจ", "๐ŸŒถ", "๐ŸงŠ", "๐Ÿฆš", "๐Ÿง€", "๐Ÿข", "๐Ÿฆฉ", "๐Ÿฅฏ", "๐Ÿฆž", "๐ŸŒผ", "๐Ÿงถ", "๐Ÿซ–", "๐Ÿ›ต", "๐Ÿงˆ", "๐Ÿชบ", +]; + +/// Four emojis from a digit string (call code or pairwise safety number). Empty or non-digit +/// input yields none. Everyone who holds the same digits sees the same four. +pub fn safety_emojis(digits: &str) -> Vec<&'static str> { + let digits: Vec = digits.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + if digits.is_empty() { + return Vec::new(); + } + (0..4) + .map(|i| { + let mut n = 0u32; + for (j, d) in digits.iter().enumerate() { + n = n.wrapping_mul(10).wrapping_add(u32::from(*d)); + n = n.wrapping_add(((i as u32) + 1).wrapping_mul((j as u32) + 1).wrapping_mul(17)); + } + n = n.wrapping_mul(0x9E37_79B1).wrapping_add((i as u32).wrapping_mul(0x85EB_CA6B)); + SAFETY_EMOJIS[(n as usize) % SAFETY_EMOJIS.len()] + }) + .collect() +} + /// What `Request::TinyE2ee` asks. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] @@ -204,6 +233,19 @@ mod tests { assert!(grouped.starts_with("01234 56789 ")); } + #[test] + fn safety_emojis_are_four_stable_glyphs_from_the_digits() { + assert!(safety_emojis("").is_empty()); + assert!(safety_emojis("abc").is_empty()); + let one = safety_emojis("686286309597067940951489038115"); + assert_eq!(one.len(), 4); + assert_eq!(one, safety_emojis("686286309597067940951489038115")); + assert_ne!(one, safety_emojis("686286309597067940951489038116")); + for glyph in &one { + assert!(SAFETY_EMOJIS.contains(glyph)); + } + } + #[test] fn the_defaults_say_nothing_and_old_plaintext_is_never_called_encrypted() { assert!(Encryption::default().is_unknown()); diff --git a/crates/tinyclient/src/gui/call.rs b/crates/tinyclient/src/gui/call.rs index 5e54e907..058143af 100644 --- a/crates/tinyclient/src/gui/call.rs +++ b/crates/tinyclient/src/gui/call.rs @@ -242,6 +242,8 @@ pub struct State { menu: Option, /// Which security indicator opened `Menu::Security`: the panel opens beside it. security_at: security::Place, + /// Whether the security panel's "More about this call" disclosure is open. + security_more: bool, /// When a click outside closed the menu, so the same click on its button does not reopen it. menu_closed: Option, /// What mediad last said is not as chosen (`Call::device_notice`), so a new one is shown. diff --git a/crates/tinyclient/src/gui/call/devices.rs b/crates/tinyclient/src/gui/call/devices.rs index bc5117ed..362d2b84 100644 --- a/crates/tinyclient/src/gui/call/devices.rs +++ b/crates/tinyclient/src/gui/call/devices.rs @@ -128,6 +128,9 @@ impl Shell { if menu == Some(Menu::Devices) { self.call_meter(true, cx); } + if before == Some(Menu::Security) && menu != Some(Menu::Security) { + self.call_state.security_more = false; + } cx.notify(); } @@ -145,7 +148,9 @@ impl Shell { .call_state .menu_closed .take() - .is_some_and(|at| at.elapsed() < Duration::from_millis(400)); + // Only long enough to absorb the button's own click after mouse_down_out closed the + // popover. 400ms also ate a deliberate reopen after dismissing, so the lock felt dead. + .is_some_and(|at| at.elapsed() < Duration::from_millis(50)); if self.call_state.menu == Some(menu) || just_closed { self.call_menu_set(None, cx); } else { diff --git a/crates/tinyclient/src/gui/call/security.rs b/crates/tinyclient/src/gui/call/security.rs index f379b560..97b206b5 100644 --- a/crates/tinyclient/src/gui/call/security.rs +++ b/crates/tinyclient/src/gui/call/security.rs @@ -11,9 +11,10 @@ //! about its protection (every network but a Tiny server). The panel opens beside the indicator //! that was clicked: over the dock's card, over the compact bar, or under the top bar. //! -//! The panel says in plain words what the server can and cannot see, shows -//! the call code everyone in the call compares, and each other member's safety number with -//! "Mark as verified". +//! The panel leads with four emojis derived from the call code (Telegram-style): everyone in +//! the call sees the same four, so reading them out loud checks that you share one encrypted +//! group. Pairwise safety numbers and what the server can still see sit under "More about this +//! call". Each other member can be marked verified. //! //! It never says more than holds. "End-to-end encrypted" and the green lock appear while mediad //! says the group has keys, and also when you are the only person in the call: there is nobody @@ -27,7 +28,7 @@ use super::{in_call, Menu}; use crate::gui::components::{glyph, tip}; use crate::gui::state::Shell; use crate::gui::theme; -use gpui::{deferred, div, prelude::*, px, AnyElement, Context, Div, FontWeight, Rgba, Stateful}; +use gpui::{deferred, div, prelude::*, px, AnyElement, Context, Div, FontWeight, MouseButton, Rgba, Stateful}; use protocol::{Call, CallSecurity, Request, SecureMember, SecurityMode, Trust}; /// Where the indicator is drawn: the panel it opens is placed by it. @@ -120,6 +121,11 @@ pub(super) fn grouped_lines(digits: &str, size: usize, per_line: usize) -> Vec Vec<&'static str> { + protocol::safety_emojis(code) +} + /// A member's name as the call's roster has it. Someone the server does not list in the call /// is said to be exactly that. pub(super) fn member_name(call: &Call, user_id: &str) -> String { @@ -141,7 +147,7 @@ fn first_name(name: &str) -> &str { /// The headline and the sentence under it. pub(super) fn summary(security: &CallSecurity, alone: bool) -> (&'static str, &'static str) { match badge(security, alone) { - Badge::EndToEnd => ("End-to-end encrypted", "Only the people in this call can hear it. The server passes along audio it cannot unlock."), + Badge::EndToEnd => ("End-to-end encrypted", "Only people in this call can hear it."), Badge::Alone => ("End-to-end encrypted", "You're the only one in this call. Your microphone stays off until someone joins, so nothing you say reaches the server unlocked."), Badge::Check => ("Check who is in this call", "The call is end-to-end encrypted, but something about who holds its keys changed. Read below before you trust it."), Badge::Pending => ("Setting up end-to-end encryption", "Your microphone waits until it's done, so nothing you say reaches the server unlocked."), @@ -261,8 +267,10 @@ impl Shell { .focus_visible(|style| style.shadow(theme::focus_ring())) .child(glyph(icon, 14., color)) .tooltip(tip(badge_words(shown), None)) + // Stop the dock title's click (open conversation) on press, not only on click: a + // parent that sees mouse_down first steals the gesture and the lock looks dead. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(cx.listener(move |this, _, _, cx| { - // The title row opens the conversation; this opens the panel alone. cx.stop_propagation(); this.call_state.security_at = place; this.call_menu_toggle(Menu::Security, cx); @@ -374,6 +382,138 @@ impl Shell { if security.mode != SecurityMode::EndToEnd { return panel; } + let emojis = call_emojis(&security.code); + if !emojis.is_empty() { + panel = panel + .child( + div() + .flex() + .justify_center() + .gap(px(8.)) + .rounded(px(10.)) + .bg(theme::hover()) + .px(px(10.)) + .py(px(12.)) + .children(emojis.into_iter().enumerate().map(|(ix, glyph)| { + div() + .id(("call-emoji", ix)) + .size(px(44.)) + .rounded(px(10.)) + .bg(theme::selected()) + .flex() + .items_center() + .justify_center() + .text_size(px(26.)) + .line_height(px(32.)) + .child(glyph) + })), + ) + .child(words( + "Same four on everyone's screen? You're in one encrypted call.".to_owned(), + theme::text_faint(), + )); + } + let in_it = in_call(call); + if !security.members.is_empty() { + let mut people = div().flex().flex_col().gap(px(6.)); + for (ix, member) in security.members.iter().enumerate() { + let name = member_name(call, &member.user_id); + let ghost = member.user_id.is_empty(); + let loud = suspect(&security, member); + let verified = member.trust == Trust::Verified; + let button = (!ghost && in_it).then(|| { + let (call, member) = (call.clone(), member.clone()); + div() + .id(("call-verify", ix)) + .flex_none() + .h(px(22.)) + .px(px(8.)) + .rounded(px(6.)) + .bg(theme::hover()) + .flex() + .items_center() + .text_size(px(11.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme::text()) + .cursor_pointer() + .hover(|style| style.bg(theme::selected())) + .child(if verified { "Unmark" } else { "Mark as verified" }) + .on_click(cx.listener(move |this, _, _, cx| this.call_verify(&call, &member, !verified, cx))) + }); + let mut row = div() + .flex() + .flex_col() + .gap(px(2.)) + .child( + div() + .flex() + .items_center() + .gap(px(6.)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(if ghost { theme::warning() } else { theme::text() }) + .child(name), + ) + .when(verified && !loud, |row| { + row.child( + div() + .flex() + .items_center() + .gap(px(4.)) + .child(glyph("icons/badge-check.svg", 13., theme::positive())) + .child( + div() + .text_size(px(11.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme::positive()) + .child("Verified"), + ), + ) + }) + .children(button), + ); + if loud { + row = row.child(words(trust_words(member).to_owned(), theme::warning())); + } + if let Some(warning) = changed_warning(call, member) { + row = row.child(words(warning, theme::warning())); + } + people = people.child(row); + } + panel = panel.child(people); + } + let more_open = self.call_state.security_more; + let more_label = if more_open { "Less about this call" } else { "More about this call" }; + panel = panel.child( + div() + .id("call-security-more") + .flex() + .items_center() + .gap(px(4.)) + .pt(px(2.)) + .cursor_pointer() + .child( + div() + .text_size(px(11.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme::text_faint()) + .child(more_label), + ) + .child(glyph(if more_open { "icons/chevron-down.svg" } else { "icons/chevron-right.svg" }, 12., theme::text_faint())) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.call_state.security_more = !this.call_state.security_more; + cx.notify(); + })), + ); + if !more_open { + return panel; + } panel = panel.child( div() .flex() @@ -383,87 +523,29 @@ impl Shell { .children(SERVER_SEES.iter().map(|seen| words(format!("ยท {seen}"), theme::text_soft()))) .child(words("It cannot hear what is said.".to_owned(), theme::text_soft())), ); - if !security.code.is_empty() { - panel = panel.child( - div() - .flex() - .flex_col() - .gap(px(4.)) - .child(label("CALL CODE")) - .child(digits(grouped_lines(&security.code, 5, 3))) - .child(words( - "Everyone in the call sees the same code. Read it out loud: the same code means you are all in one encrypted group, not split by the server. Check the list below for anyone you don't expect. It changes whenever someone joins or leaves." - .to_owned(), - theme::text_faint(), - )), - ); - } if security.members.is_empty() { return panel; } - let in_it = in_call(call); - let mut people = div().flex().flex_col().gap(px(8.)).child(label("SAFETY NUMBERS")); - for (ix, member) in security.members.iter().enumerate() { + let mut detail = div().flex().flex_col().gap(px(8.)).child(label("SAFETY NUMBERS")); + for member in &security.members { let name = member_name(call, &member.user_id); - let ghost = member.user_id.is_empty(); - let loud = suspect(&security, member); - let state_color = match member.trust { - _ if loud => theme::warning(), - Trust::Verified => theme::positive(), - _ => theme::text_faint(), - }; - let verified = member.trust == Trust::Verified; - let button = (!ghost && in_it).then(|| { - let (call, member) = (call.clone(), member.clone()); - div() - .id(("call-verify", ix)) - .flex_none() - .h(px(22.)) - .px(px(8.)) - .rounded(px(6.)) - .bg(theme::hover()) - .flex() - .items_center() - .text_size(px(11.5)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme::text()) - .cursor_pointer() - .hover(|style| style.bg(theme::selected())) - .child(if verified { "Unmark" } else { "Mark as verified" }) - .on_click(cx.listener(move |this, _, _, cx| this.call_verify(&call, &member, !verified, cx))) - }); let mut row = div() .flex() .flex_col() .gap(px(3.)) .child( div() - .flex() - .items_center() - .gap(px(6.)) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(px(12.5)) - .font_weight(FontWeight::MEDIUM) - .text_color(if ghost { theme::warning() } else { theme::text() }) - .child(name), - ) - .when(verified && !loud, |row| row.child(glyph("icons/badge-check.svg", 13., theme::positive()))) - .children(button), - ) - .child(words(trust_words(member).to_owned(), state_color)); - if let Some(warning) = changed_warning(call, member) { - row = row.child(words(warning, theme::warning())); - } + .text_size(px(12.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme::text()) + .child(name), + ); if !member.safety.is_empty() { row = row.child(digits(grouped_lines(&member.safety, 5, 3))); } - people = people.child(row); + detail = detail.child(row); } - panel.child(people).child(words( + panel.child(detail).child(words( "Compare a safety number with that person, in person or on another channel you trust. Both of you see the same number." .to_owned(), theme::text_faint(), @@ -556,6 +638,16 @@ mod tests { assert_eq!(grouped("", 5), ""); } + #[test] + fn call_emojis_are_four_stable_glyphs_from_the_code() { + assert!(call_emojis("").is_empty()); + assert!(call_emojis("abc").is_empty()); + let one = call_emojis("686286309597067940951489038115"); + assert_eq!(one.len(), 4); + assert_eq!(one, call_emojis("686286309597067940951489038115")); + assert_ne!(one, call_emojis("686286309597067940951489038116")); + } + #[test] fn members_are_named_from_the_roster_and_an_unlisted_one_says_so() { let call = call(secure(vec![])); diff --git a/crates/tinyclient/src/gui/tiny/settings/encryption.rs b/crates/tinyclient/src/gui/tiny/settings/encryption.rs index eea6f947..e2880709 100644 --- a/crates/tinyclient/src/gui/tiny/settings/encryption.rs +++ b/crates/tinyclient/src/gui/tiny/settings/encryption.rs @@ -1,5 +1,6 @@ //! Encryption: this device and the person's other devices, approving a new one, the people this -//! account shares encrypted conversations with and their safety numbers, and the recovery key. +//! account shares encrypted conversations with (four emojis from each safety number, Telegram- +//! style), and the recovery key. //! //! It says what holds and no more. A device on the server's list that no device of its owner //! signed is shown as not approved: the server may have added it, and nothing is encrypted to it. @@ -15,8 +16,8 @@ use crate::gui::theme::{self, Type}; use crate::gui::tiny::Page as Facts; use gpui::{div, prelude::*, px, AnyElement, ClipboardItem, Context, Div, SharedString, Window}; use protocol::{ - grouped_safety, DeviceState, E2eeAsk, E2eeDevice, E2eePerson, E2eeStatus, Request, Response, - Trust, + grouped_safety, safety_emojis, DeviceState, E2eeAsk, E2eeDevice, E2eePerson, E2eeStatus, Request, + Response, Trust, }; /// What the page has read and what it is doing. @@ -314,7 +315,7 @@ pub(in crate::gui::tiny) fn encryption( let fields = &shell.tiny_state.settings_fields; let mut page = div().flex().flex_col().gap(px(20.)).child(page_head( "Encryption", - "End-to-end encrypted conversations can be read only on the members' devices. The server stores ciphertext and can't read them. It still sees who is in a conversation, when messages are sent and how large they are.", + "Only people's devices can read end-to-end encrypted conversations. The server stores ciphertext.", )); if let Some(error) = &draft.error { page = page.child(kit::note( @@ -528,14 +529,36 @@ pub(in crate::gui::tiny) fn encryption( (_, Trust::Verified) => theme::positive(), _ => theme::text_soft(), }; - let safety = if person.safety.is_empty() { - "No safety number yet".to_owned() + let emojis = safety_emojis(&person.safety); + let emoji_row = if emojis.is_empty() { + div() + .type_small() + .text_color(theme::text_faint()) + .child("No key to compare yet") + .into_any_element() } else { - grouped_safety(&person.safety) + div() + .flex() + .flex_wrap() + .gap(px(6.)) + .children(emojis.into_iter().enumerate().map(|(ei, face)| { + div() + .id(SharedString::from(format!("tiny-e2ee-emoji-{ix}-{ei}"))) + .size(px(36.)) + .rounded(px(8.)) + .bg(theme::hover()) + .flex() + .items_center() + .justify_center() + .text_size(px(20.)) + .line_height(px(24.)) + .child(face) + })) + .into_any_element() }; people = people.child( kit::table_row(SharedString::from(format!("tiny-e2ee-person-{ix}")), false) - // On a phone the buttons go under the number, which keeps the width it needs. + // On a phone the buttons go under the key, which keeps the width it needs. .when(crate::gui::phone::active(), |row| row.flex_col().items_start().gap(px(6.))) .child( div() @@ -543,23 +566,37 @@ pub(in crate::gui::tiny) fn encryption( .min_w_0() .flex() .flex_col() - .gap(px(2.)) + .gap(px(6.)) .child( div() - .truncate() - .type_ui() - .font_weight(theme::MEDIUM) - .text_color(theme::text()) - .child(name), - ) - .child( - div() - .whitespace_normal() - .type_small() - .font_family(theme::MONO) - .text_color(theme::text()) - .child(safety), + .flex() + .items_center() + .gap(px(6.)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .type_ui() + .font_weight(theme::MEDIUM) + .text_color(theme::text()) + .child(name), + ) + .when(verified && !person.identity_changed && person.trust != Trust::Changed, |row| { + row.child(glyph("icons/badge-check.svg", 14., theme::positive())) + }), ) + .child(emoji_row) + .when(!person.safety.is_empty(), |column| { + column.child( + div() + .whitespace_normal() + .type_small() + .font_family(theme::MONO) + .text_color(theme::text_faint()) + .child(grouped_safety(&person.safety)), + ) + }) .child( div() .whitespace_normal() @@ -598,8 +635,8 @@ pub(in crate::gui::tiny) fn encryption( ); } page = page.child( - super::section("Safety numbers") - .child(div().whitespace_normal().type_small().text_color(theme::text_soft()).child("Compare a safety number with the person in a call or face to face. If it matches on both screens, nobody, the server included, has put a device of their own between you.")) + super::section("People") + .child(div().whitespace_normal().type_small().text_color(theme::text_soft()).child("Same four emojis on both screens? Nobody, the server included, has put a device between you. Digits stay under the emojis for a precise check.")) .child(people), ); if let Some(person) = &draft.person {