mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 07:32:12 +03:00
Compare commits
7 Commits
link2xt/cu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1219cbe1a3 | ||
|
|
bc48b17e93 | ||
|
|
7233b4b811 | ||
|
|
d1e0088201 | ||
|
|
a5e41b0b49 | ||
|
|
2f76fd98dd | ||
|
|
6235f2a01a |
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -2654,7 +2654,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.9",
|
||||
"socket2 0.6.0",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2860,9 +2860,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
@@ -3483,9 +3483,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.5"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
@@ -4615,9 +4615,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
|
||||
@@ -1047,6 +1047,7 @@ def test_no_old_msg_is_fresh(acfactory):
|
||||
assert ac1.create_chat(ac2).get_fresh_message_count() == 1
|
||||
assert len(list(ac1.get_fresh_messages())) == 1
|
||||
|
||||
ac1_clone.wait_for_incoming_msg_event()
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
|
||||
logging.info("Send a message from ac1_clone to ac2 and check that ac1 marks the first message as 'noticed'")
|
||||
|
||||
@@ -288,6 +288,7 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
|
||||
assert open(contact.get_profile_image(), "rb").read() == open(avatar_path, "rb").read()
|
||||
|
||||
lp.sec("ac2_offl: sending message")
|
||||
chat2.accept()
|
||||
msg_out = chat2.send_text("hello")
|
||||
|
||||
lp.sec("ac1: receiving message")
|
||||
|
||||
36
src/blob.rs
36
src/blob.rs
@@ -10,8 +10,8 @@ use anyhow::{Context as _, Result, ensure, format_err};
|
||||
use base64::Engine as _;
|
||||
use futures::StreamExt;
|
||||
use image::ImageReader;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
|
||||
use image::{codecs::jpeg::JpegEncoder, metadata::Orientation};
|
||||
use num_traits::FromPrimitive;
|
||||
use tokio::{fs, task};
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
@@ -362,7 +362,10 @@ impl<'a> BlobObject<'a> {
|
||||
return Ok(name);
|
||||
}
|
||||
let mut img = imgreader.decode().context("image decode failure")?;
|
||||
let orientation = exif.as_ref().map(|exif| exif_orientation(exif, context));
|
||||
let orientation = exif
|
||||
.as_ref()
|
||||
.map(|exif| exif_orientation(exif, context))
|
||||
.unwrap_or(Orientation::NoTransforms);
|
||||
let mut encoded = Vec::new();
|
||||
|
||||
if *vt == Viewtype::Sticker {
|
||||
@@ -381,13 +384,7 @@ impl<'a> BlobObject<'a> {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
|
||||
img = match orientation {
|
||||
Some(90) => img.rotate90(),
|
||||
Some(180) => img.rotate180(),
|
||||
Some(270) => img.rotate270(),
|
||||
_ => img,
|
||||
};
|
||||
img.apply_orientation(orientation);
|
||||
|
||||
// max_wh is the maximum image width and height, i.e. the resolution-limit.
|
||||
// target_wh target-resolution for resizing the image.
|
||||
@@ -551,18 +548,17 @@ fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
|
||||
Ok((len, exif))
|
||||
}
|
||||
|
||||
fn exif_orientation(exif: &exif::Exif, context: &Context) -> i32 {
|
||||
if let Some(orientation) = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
||||
// possible orientation values are described at http://sylvana.net/jpegcrop/exif_orientation.html
|
||||
// we only use rotation, in practise, flipping is not used.
|
||||
match orientation.value.get_uint(0) {
|
||||
Some(3) => return 180,
|
||||
Some(6) => return 90,
|
||||
Some(8) => return 270,
|
||||
other => warn!(context, "Exif orientation value ignored: {other:?}."),
|
||||
}
|
||||
fn exif_orientation(exif: &exif::Exif, context: &Context) -> Orientation {
|
||||
if let Some(orientation) = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
&& let Some(val) = orientation.value.get_uint(0)
|
||||
&& let Ok(val) = TryInto::<u8>::try_into(val)
|
||||
{
|
||||
return Orientation::from_exif(val).unwrap_or({
|
||||
warn!(context, "Exif orientation value ignored: {val:?}.");
|
||||
Orientation::NoTransforms
|
||||
});
|
||||
}
|
||||
0
|
||||
Orientation::NoTransforms
|
||||
}
|
||||
|
||||
/// All files in the blobdir.
|
||||
|
||||
@@ -305,7 +305,7 @@ async fn test_recode_image_2() {
|
||||
has_exif: true,
|
||||
original_width: 2000,
|
||||
original_height: 1800,
|
||||
orientation: 270,
|
||||
orientation: Some(Orientation::Rotate270),
|
||||
compressed_width: 1800,
|
||||
compressed_height: 2000,
|
||||
..Default::default()
|
||||
@@ -336,6 +336,28 @@ async fn test_recode_image_2() {
|
||||
assert_correct_rotation(&img_rotated);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recode_image_vflipped() {
|
||||
let bytes = include_bytes!("../../test-data/image/rectangle200x180-vflipped.jpg");
|
||||
let img_rotated = SendImageCheckMediaquality {
|
||||
viewtype: Viewtype::Image,
|
||||
media_quality_config: "0",
|
||||
bytes,
|
||||
extension: "jpg",
|
||||
has_exif: true,
|
||||
original_width: 200,
|
||||
original_height: 180,
|
||||
orientation: Some(Orientation::FlipVertical),
|
||||
compressed_width: 200,
|
||||
compressed_height: 180,
|
||||
..Default::default()
|
||||
}
|
||||
.test()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_correct_rotation(&img_rotated);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recode_image_bad_exif() {
|
||||
// `exiftool` reports for this file "Bad offset for IFD0 XResolution", still Exif must be
|
||||
@@ -530,7 +552,7 @@ struct SendImageCheckMediaquality<'a> {
|
||||
pub(crate) has_exif: bool,
|
||||
pub(crate) original_width: u32,
|
||||
pub(crate) original_height: u32,
|
||||
pub(crate) orientation: i32,
|
||||
pub(crate) orientation: Option<Orientation>,
|
||||
pub(crate) res_viewtype: Option<Viewtype>,
|
||||
pub(crate) compressed_width: u32,
|
||||
pub(crate) compressed_height: u32,
|
||||
@@ -546,7 +568,7 @@ impl SendImageCheckMediaquality<'_> {
|
||||
let has_exif = self.has_exif;
|
||||
let original_width = self.original_width;
|
||||
let original_height = self.original_height;
|
||||
let orientation = self.orientation;
|
||||
let orientation = self.orientation.unwrap_or(Orientation::NoTransforms);
|
||||
let res_viewtype = self.res_viewtype.unwrap_or(Viewtype::Image);
|
||||
let compressed_width = self.compressed_width;
|
||||
let compressed_height = self.compressed_height;
|
||||
|
||||
@@ -3768,14 +3768,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
// The contact should be marked as verified.
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(alice, bob0).await;
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob0, alice).await;
|
||||
|
||||
// TODO: There is a known bug in `observe_securejoin_on_other_device()`:
|
||||
// When Bob joins a group or broadcast with his first device,
|
||||
// then a chat with Alice will pop up on his second device.
|
||||
// When it's fixed, the 2 following lines can be replaced with
|
||||
// `check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;`
|
||||
let bob1_alice_contact = bob1.add_or_lookup_contact_no_key(alice).await;
|
||||
assert!(bob1_alice_contact.is_verified(bob1).await.unwrap());
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;
|
||||
|
||||
tcm.section("Alice sends first message to broadcast.");
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::key::self_fingerprint;
|
||||
use crate::log::warn;
|
||||
use crate::logged_debug_assert;
|
||||
use crate::message::{self, MessageState, MsgId};
|
||||
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
|
||||
use crate::net::tls::TlsSessionStore;
|
||||
use crate::peer_channels::Iroh;
|
||||
use crate::push::PushSubscriber;
|
||||
use crate::quota::QuotaInfo;
|
||||
@@ -308,13 +308,6 @@ pub struct InnerContext {
|
||||
/// TLS session resumption cache.
|
||||
pub(crate) tls_session_store: TlsSessionStore,
|
||||
|
||||
/// Store for TLS SPKI hashes.
|
||||
///
|
||||
/// Used to remember public keys
|
||||
/// of TLS certificates to accept them
|
||||
/// even after they expire.
|
||||
pub(crate) spki_hash_store: SpkiHashStore,
|
||||
|
||||
/// Iroh for realtime peer channels.
|
||||
pub(crate) iroh: Arc<RwLock<Option<Iroh>>>,
|
||||
|
||||
@@ -518,7 +511,6 @@ impl Context {
|
||||
push_subscriber,
|
||||
push_subscribed: AtomicBool::new(false),
|
||||
tls_session_store: TlsSessionStore::new(),
|
||||
spki_hash_store: SpkiHashStore::new(),
|
||||
iroh: Arc::new(RwLock::new(None)),
|
||||
self_fingerprint: OnceLock::new(),
|
||||
self_public_key: Mutex::new(None),
|
||||
|
||||
@@ -220,8 +220,6 @@ impl Client {
|
||||
alpn(addr.port()),
|
||||
logging_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
let buffered_stream = BufWriter::new(tls_stream);
|
||||
@@ -284,8 +282,6 @@ impl Client {
|
||||
"",
|
||||
tcp_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await
|
||||
.context("STARTTLS upgrade failed")?;
|
||||
@@ -314,8 +310,6 @@ impl Client {
|
||||
alpn(port),
|
||||
proxy_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
let buffered_stream = BufWriter::new(tls_stream);
|
||||
@@ -379,8 +373,6 @@ impl Client {
|
||||
"",
|
||||
proxy_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await
|
||||
.context("STARTTLS upgrade failed")?;
|
||||
|
||||
@@ -12,7 +12,7 @@ use tokio_io_timeout::TimeoutStream;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::net::session::SessionStream;
|
||||
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
|
||||
use crate::net::tls::TlsSessionStore;
|
||||
use crate::sql::Sql;
|
||||
use crate::tools::time;
|
||||
|
||||
@@ -130,8 +130,6 @@ pub(crate) async fn connect_tls_inner(
|
||||
strict_tls: bool,
|
||||
alpn: &str,
|
||||
tls_session_store: &TlsSessionStore,
|
||||
spki_hash_store: &SpkiHashStore,
|
||||
sql: &Sql,
|
||||
) -> Result<impl SessionStream + 'static> {
|
||||
let use_sni = true;
|
||||
let tcp_stream = connect_tcp_inner(addr).await?;
|
||||
@@ -143,8 +141,6 @@ pub(crate) async fn connect_tls_inner(
|
||||
alpn,
|
||||
tcp_stream,
|
||||
tls_session_store,
|
||||
spki_hash_store,
|
||||
sql,
|
||||
)
|
||||
.await?;
|
||||
Ok(tls_stream)
|
||||
|
||||
@@ -87,8 +87,6 @@ where
|
||||
"",
|
||||
proxy_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
Box::new(tls_stream)
|
||||
@@ -101,8 +99,6 @@ where
|
||||
"",
|
||||
tcp_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
Box::new(tls_stream)
|
||||
|
||||
@@ -439,8 +439,6 @@ impl ProxyConfig {
|
||||
"",
|
||||
tcp_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
let auth = if let Some((username, password)) = &https_config.user_password {
|
||||
|
||||
@@ -6,19 +6,13 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::net::session::SessionStream;
|
||||
use crate::sql::Sql;
|
||||
|
||||
use tokio_rustls::rustls;
|
||||
use tokio_rustls::rustls::client::ClientSessionStore;
|
||||
use tokio_rustls::rustls::server::ParsedCertificate;
|
||||
|
||||
mod danger;
|
||||
use danger::CustomCertificateVerifier;
|
||||
use danger::NoCertificateVerification;
|
||||
|
||||
mod spki;
|
||||
pub use spki::SpkiHashStore;
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub async fn wrap_tls<'a>(
|
||||
strict_tls: bool,
|
||||
hostname: &str,
|
||||
@@ -27,21 +21,10 @@ pub async fn wrap_tls<'a>(
|
||||
alpn: &str,
|
||||
stream: impl SessionStream + 'static,
|
||||
tls_session_store: &TlsSessionStore,
|
||||
spki_hash_store: &SpkiHashStore,
|
||||
sql: &Sql,
|
||||
) -> Result<impl SessionStream + 'a> {
|
||||
if strict_tls {
|
||||
let tls_stream = wrap_rustls(
|
||||
hostname,
|
||||
port,
|
||||
use_sni,
|
||||
alpn,
|
||||
stream,
|
||||
tls_session_store,
|
||||
spki_hash_store,
|
||||
sql,
|
||||
)
|
||||
.await?;
|
||||
let tls_stream =
|
||||
wrap_rustls(hostname, port, use_sni, alpn, stream, tls_session_store).await?;
|
||||
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
|
||||
Ok(boxed_stream)
|
||||
} else {
|
||||
@@ -111,7 +94,6 @@ impl TlsSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub async fn wrap_rustls<'a>(
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
@@ -119,8 +101,6 @@ pub async fn wrap_rustls<'a>(
|
||||
alpn: &str,
|
||||
stream: impl SessionStream + 'a,
|
||||
tls_session_store: &TlsSessionStore,
|
||||
spki_hash_store: &SpkiHashStore,
|
||||
sql: &Sql,
|
||||
) -> Result<impl SessionStream + 'a> {
|
||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
@@ -147,27 +127,20 @@ pub async fn wrap_rustls<'a>(
|
||||
config.resumption = resumption;
|
||||
config.enable_sni = use_sni;
|
||||
|
||||
config
|
||||
.dangerous()
|
||||
.set_certificate_verifier(Arc::new(CustomCertificateVerifier::new(
|
||||
spki_hash_store.get_spki_hash(hostname, sql).await?,
|
||||
)));
|
||||
// Do not verify certificates for hostnames starting with `_`.
|
||||
// They are used for servers with self-signed certificates, e.g. for local testing.
|
||||
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
|
||||
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
|
||||
// explicitly state that domains should start with a letter, digit or hyphen:
|
||||
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
|
||||
if hostname.starts_with("_") {
|
||||
config
|
||||
.dangerous()
|
||||
.set_certificate_verifier(Arc::new(NoCertificateVerification::new()));
|
||||
}
|
||||
|
||||
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
|
||||
let name = tokio_rustls::rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
|
||||
let tls_stream = tls.connect(name, stream).await?;
|
||||
|
||||
// Successfully connected.
|
||||
// Remember SPKI hash to accept it later if certificate expires.
|
||||
let (_io, client_connection) = tls_stream.get_ref();
|
||||
if let Some(end_entity) = client_connection
|
||||
.peer_certificates()
|
||||
.and_then(|certs| certs.first())
|
||||
{
|
||||
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
|
||||
let spki = parsed_certificate.subject_public_key_info();
|
||||
spki_hash_store.save_spki(hostname, &spki, sql).await?;
|
||||
}
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
@@ -1,85 +1,26 @@
|
||||
//! Custom TLS verification.
|
||||
//!
|
||||
//! We want to accept expired certificates.
|
||||
//! Dangerous TLS implementation of accepting invalid certificates for Rustls.
|
||||
|
||||
use rustls::RootCertStore;
|
||||
use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::server::ParsedCertificate;
|
||||
use tokio_rustls::rustls;
|
||||
|
||||
use crate::net::tls::spki::spki_hash;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct CustomCertificateVerifier {
|
||||
/// Root certificates.
|
||||
root_cert_store: RootCertStore,
|
||||
pub(super) struct NoCertificateVerification();
|
||||
|
||||
/// Expected SPKI hash as a base64 of SHA-256.
|
||||
spki_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomCertificateVerifier {
|
||||
pub(super) fn new(spki_hash: Option<String>) -> Self {
|
||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
Self {
|
||||
root_cert_store,
|
||||
spki_hash,
|
||||
}
|
||||
impl NoCertificateVerification {
|
||||
pub(super) fn new() -> Self {
|
||||
Self()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
|
||||
impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
intermediates: &[CertificateDer<'_>],
|
||||
server_name: &ServerName<'_>,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
now: UnixTime,
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
|
||||
|
||||
let spki = parsed_certificate.subject_public_key_info();
|
||||
|
||||
let provider = rustls::crypto::ring::default_provider();
|
||||
|
||||
if let ServerName::DnsName(dns_name) = server_name
|
||||
&& dns_name.as_ref().starts_with("_")
|
||||
{
|
||||
// Do not verify certificates for hostnames starting with `_`.
|
||||
// They are used for servers with self-signed certificates, e.g. for local testing.
|
||||
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
|
||||
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
|
||||
// explicitly state that domains should start with a letter, digit or hyphen:
|
||||
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
|
||||
} else if let Some(hash) = &self.spki_hash
|
||||
&& spki_hash(&spki) == *hash
|
||||
{
|
||||
// Last time we successfully connected to this hostname with TLS checks,
|
||||
// SPKI had this hash.
|
||||
// It does not matter if certificate has now expired.
|
||||
} else {
|
||||
// verify_server_cert_signed_by_trust_anchor does no revocation checking:
|
||||
// <https://docs.rs/rustls/0.23.37/rustls/client/fn.verify_server_cert_signed_by_trust_anchor.html>
|
||||
// We don't do it either.
|
||||
verify_server_cert_signed_by_trust_anchor(
|
||||
&parsed_certificate,
|
||||
&self.root_cert_store,
|
||||
intermediates,
|
||||
now,
|
||||
provider.signature_verification_algorithms.all,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Verify server name unconditionally.
|
||||
//
|
||||
// We do this even for self-signed certificates when hostname starts with `_`
|
||||
// so we don't try to connect to captive portals
|
||||
// and fail on MITM certificates if they are generated once
|
||||
// and reused for all hostnames.
|
||||
verify_server_name(&parsed_certificate, server_name)?;
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//! SPKI hash storage.
|
||||
//!
|
||||
//! We store hashes of Subject Public Key Info from TLS certificates
|
||||
//! after successful connection to allow connecting when
|
||||
//! server certificate expires as long as the key is not changed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use parking_lot::RwLock;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_rustls::rustls::pki_types::SubjectPublicKeyInfoDer;
|
||||
|
||||
use crate::sql::Sql;
|
||||
|
||||
/// Calculates Subject Public Key Info SHA-256 hash and returns it as base64.
|
||||
///
|
||||
/// This is the same format as used in <https://www.rfc-editor.org/rfc/rfc7469>.
|
||||
/// You can calculate the same hash for any remote host with
|
||||
/// ```sh
|
||||
/// openssl s_client -connect "$HOST:993" -servername "$HOST" </dev/null 2>/dev/null |
|
||||
/// openssl x509 -pubkey -noout |
|
||||
/// openssl pkey -pubin -outform der |
|
||||
/// openssl dgst -sha256 -binary |
|
||||
/// openssl enc -base64
|
||||
/// ```
|
||||
pub fn spki_hash(spki: &SubjectPublicKeyInfoDer) -> String {
|
||||
let spki_hash = Sha256::digest(spki);
|
||||
base64::engine::general_purpose::STANDARD.encode(spki_hash)
|
||||
}
|
||||
|
||||
/// Write-through cache for SPKI hashes.
|
||||
#[derive(Debug)]
|
||||
pub struct SpkiHashStore {
|
||||
/// Map from hostnames to base64 of SHA-256 hashes.
|
||||
pub hash_store: RwLock<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SpkiHashStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
hash_store: RwLock::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns base64 of SPKI hash if we have previously successfully connected to given hostname.
|
||||
pub async fn get_spki_hash(&self, hostname: &str, sql: &Sql) -> Result<Option<String>> {
|
||||
if let Some(hash) = self.hash_store.read().get(hostname).cloned() {
|
||||
return Ok(Some(hash));
|
||||
}
|
||||
|
||||
match sql
|
||||
.query_row_optional(
|
||||
"SELECT spki_hash FROM tls_spki WHERE host=?",
|
||||
(hostname,),
|
||||
|row| {
|
||||
let spki_hash: String = row.get(0)?;
|
||||
Ok(spki_hash)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(hash) => {
|
||||
self.hash_store
|
||||
.write()
|
||||
.insert(hostname.to_string(), hash.clone());
|
||||
Ok(Some(hash))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves SPKI hash after successful connection.
|
||||
pub async fn save_spki(
|
||||
&self,
|
||||
hostname: &str,
|
||||
spki: &SubjectPublicKeyInfoDer<'_>,
|
||||
sql: &Sql,
|
||||
) -> Result<()> {
|
||||
let hash = spki_hash(spki);
|
||||
self.hash_store
|
||||
.write()
|
||||
.insert(hostname.to_string(), hash.clone());
|
||||
sql.execute(
|
||||
"INSERT OR REPLACE INTO tls_spki (host, spki_hash) VALUES (?, ?)",
|
||||
(hostname, hash),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3562,7 +3562,14 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
chattype,
|
||||
&listid,
|
||||
name,
|
||||
create_blocked,
|
||||
if chattype == Chattype::InBroadcast {
|
||||
// If we joined the broadcast, we have scanned a QR code.
|
||||
// Even if 1:1 chat does not exist or is in a contact request,
|
||||
// create the channel as unblocked.
|
||||
Blocked::Not
|
||||
} else {
|
||||
create_blocked
|
||||
},
|
||||
param,
|
||||
mime_parser.timestamp_sent,
|
||||
)
|
||||
|
||||
@@ -5363,6 +5363,46 @@ async fn test_outgoing_unencrypted_chat_assignment() {
|
||||
assert_eq!(received.chat_id, chat.id);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_incoming_reply_with_date_in_past() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
let msg0 = receive_imf(
|
||||
alice,
|
||||
b"From: bob@example.net\n\
|
||||
To: alice@example.org\n\
|
||||
Message-ID: <message@example.net>\n\
|
||||
Date: Sun, 22 Mar 2020 22:22:22 +0000\n\
|
||||
\n\
|
||||
This device has an atomic clock\n",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
|
||||
let msg1 = receive_imf(
|
||||
alice,
|
||||
b"From: bob@example.net\n\
|
||||
To: alice@example.org\n\
|
||||
Message-ID: <message1@example.net>\n\
|
||||
In-Reply-To: <message@example.net>\n\
|
||||
Date: Sun, 22 Mar 2020 11:11:11 +0000\n\
|
||||
\n\
|
||||
And this one has a wind-up clock\n",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(msg1.chat_id, msg0.chat_id);
|
||||
assert!(msg1.sort_timestamp >= msg0.sort_timestamp);
|
||||
assert_eq!(
|
||||
alice.get_last_msg_in(msg0.chat_id).await.id,
|
||||
*msg1.msg_ids.last().unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests Bob receiving a message from Alice
|
||||
/// in a new group she just created
|
||||
/// with only Alice and Bob.
|
||||
@@ -5590,3 +5630,62 @@ async fn test_outgoing_determined_by_signature() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_mark_message_as_delivered_only_after_sent_out_fully() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
alice.set_config_bool(Config::BccSelf, true).await?;
|
||||
let alice_chat_id = alice.create_chat_id(bob).await;
|
||||
|
||||
let file_bytes = include_bytes!("../../test-data/image/screenshot.gif");
|
||||
let mut msg = Message::new(Viewtype::Image);
|
||||
msg.set_file_from_bytes(alice, "a.jpg", file_bytes, None)?;
|
||||
let msg_id = chat::send_msg(alice, alice_chat_id, &mut msg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (pre_msg_id, pre_msg_payload) = first_row_in_smtp_queue(alice).await;
|
||||
assert_eq!(msg_id, pre_msg_id);
|
||||
assert!(pre_msg_payload.len() < file_bytes.len());
|
||||
|
||||
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
|
||||
// Alice receives her own pre-message because of bcc_self
|
||||
// This should not yet mark the message as delivered,
|
||||
// because not everything was sent,
|
||||
// but it does remove the pre-message from the SMTP queue
|
||||
receive_imf(alice, pre_msg_payload.as_bytes(), false).await?;
|
||||
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
|
||||
|
||||
let (post_msg_id, post_msg_payload) = first_row_in_smtp_queue(alice).await;
|
||||
assert_eq!(msg_id, post_msg_id);
|
||||
assert!(post_msg_payload.len() > file_bytes.len());
|
||||
|
||||
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
|
||||
// Alice receives her own post-message because of bcc_self
|
||||
// This should now mark the message as delivered,
|
||||
// because everything was sent by now.
|
||||
receive_imf(alice, post_msg_payload.as_bytes(), false).await?;
|
||||
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutDelivered);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queries the first sent message in the SMTP queue
|
||||
/// without removing it from the SMTP queue.
|
||||
/// This simulates the case that a message is successfully sent out,
|
||||
/// but the 'OK' answer from the server doesn't arrive,
|
||||
/// so that the SMTP row stays in the database.
|
||||
pub(crate) async fn first_row_in_smtp_queue(alice: &TestContext) -> (MsgId, String) {
|
||||
alice
|
||||
.sql
|
||||
.query_row_optional("SELECT msg_id, mime FROM smtp ORDER BY id", (), |row| {
|
||||
let msg_id: MsgId = row.get(0)?;
|
||||
let mime: String = row.get(1)?;
|
||||
Ok((msg_id, mime))
|
||||
})
|
||||
.await
|
||||
.expect("query_row_optional failed")
|
||||
.expect("No SMTP row found")
|
||||
}
|
||||
|
||||
@@ -839,13 +839,6 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
inviter_progress(context, contact_id, chat_id, chat_type)?;
|
||||
}
|
||||
|
||||
if matches!(step, SecureJoinStep::RequestWithAuth) {
|
||||
// This actually reflects what happens on the first device (which does the secure
|
||||
// join) and causes a subsequent "vg-member-added" message to create an unblocked
|
||||
// verified group.
|
||||
ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await?;
|
||||
}
|
||||
|
||||
if matches!(step, SecureJoinStep::MemberAdded) {
|
||||
Ok(HandshakeMessage::Propagate)
|
||||
} else {
|
||||
|
||||
@@ -11,12 +11,11 @@ use crate::log::warn;
|
||||
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::net::session::SessionBufStream;
|
||||
use crate::net::tls::{SpkiHashStore, TlsSessionStore, wrap_tls};
|
||||
use crate::net::tls::{TlsSessionStore, wrap_tls};
|
||||
use crate::net::{
|
||||
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
|
||||
};
|
||||
use crate::oauth2::get_oauth2_access_token;
|
||||
use crate::sql::Sql;
|
||||
use crate::tools::time;
|
||||
use crate::transport::ConnectionCandidate;
|
||||
use crate::transport::ConnectionSecurity;
|
||||
@@ -112,26 +111,10 @@ async fn connection_attempt(
|
||||
);
|
||||
let res = match security {
|
||||
ConnectionSecurity::Tls => {
|
||||
connect_secure(
|
||||
resolved_addr,
|
||||
host,
|
||||
strict_tls,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await
|
||||
connect_secure(resolved_addr, host, strict_tls, &context.tls_session_store).await
|
||||
}
|
||||
ConnectionSecurity::Starttls => {
|
||||
connect_starttls(
|
||||
resolved_addr,
|
||||
host,
|
||||
strict_tls,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await
|
||||
connect_starttls(resolved_addr, host, strict_tls, &context.tls_session_store).await
|
||||
}
|
||||
ConnectionSecurity::Plain => connect_insecure(resolved_addr).await,
|
||||
};
|
||||
@@ -257,8 +240,6 @@ async fn connect_secure_proxy(
|
||||
alpn(port),
|
||||
proxy_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await?;
|
||||
let mut buffered_stream = BufStream::new(tls_stream);
|
||||
@@ -292,8 +273,6 @@ async fn connect_starttls_proxy(
|
||||
"",
|
||||
tcp_stream,
|
||||
&context.tls_session_store,
|
||||
&context.spki_hash_store,
|
||||
&context.sql,
|
||||
)
|
||||
.await
|
||||
.context("STARTTLS upgrade failed")?;
|
||||
@@ -320,8 +299,6 @@ async fn connect_secure(
|
||||
hostname: &str,
|
||||
strict_tls: bool,
|
||||
tls_session_store: &TlsSessionStore,
|
||||
spki_hash_store: &SpkiHashStore,
|
||||
sql: &Sql,
|
||||
) -> Result<Box<dyn SessionBufStream>> {
|
||||
let tls_stream = connect_tls_inner(
|
||||
addr,
|
||||
@@ -329,8 +306,6 @@ async fn connect_secure(
|
||||
strict_tls,
|
||||
alpn(addr.port()),
|
||||
tls_session_store,
|
||||
spki_hash_store,
|
||||
sql,
|
||||
)
|
||||
.await?;
|
||||
let mut buffered_stream = BufStream::new(tls_stream);
|
||||
@@ -344,8 +319,6 @@ async fn connect_starttls(
|
||||
host: &str,
|
||||
strict_tls: bool,
|
||||
tls_session_store: &TlsSessionStore,
|
||||
spki_hash_store: &SpkiHashStore,
|
||||
sql: &Sql,
|
||||
) -> Result<Box<dyn SessionBufStream>> {
|
||||
let use_sni = false;
|
||||
let tcp_stream = connect_tcp_inner(addr).await?;
|
||||
@@ -363,8 +336,6 @@ async fn connect_starttls(
|
||||
"",
|
||||
tcp_stream,
|
||||
tls_session_store,
|
||||
spki_hash_store,
|
||||
sql,
|
||||
)
|
||||
.await
|
||||
.context("STARTTLS upgrade failed")?;
|
||||
|
||||
@@ -2316,18 +2316,6 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 150)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration(
|
||||
"CREATE TABLE tls_spki (
|
||||
host TEXT NOT NULL UNIQUE,
|
||||
spki_hash TEXT NOT NULL -- base64 of SPKI SHA-256 hash
|
||||
) STRICT",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
BIN
test-data/image/rectangle200x180-vflipped.jpg
Normal file
BIN
test-data/image/rectangle200x180-vflipped.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user