Compare commits

...

2 Commits

Author SHA1 Message Date
link2xt
430cccfd32 feat: enable anonymous OpenPGP key IDs
This was disabled for interoperability in
098084b9a7,
enabling it back now.
2026-03-16 02:04:20 +00:00
link2xt
822a99ea9c fix: do not send MDNs for hidden messages
Hidden messages are marked as seen
when chat is marked as noticed.
MDNs to such messages should not be sent
as this notifies the hidden message sender
that the chat was opened.

The issue discovered by Frank Seifferth.
2026-03-15 20:54:50 +00:00
5 changed files with 158 additions and 38 deletions

View File

@@ -40,7 +40,6 @@ impl EncryptHelper {
keyring: Vec<SignedPublicKey>,
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -49,15 +48,8 @@ impl EncryptHelper {
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
sign_key,
compress,
anonymous_recipients,
seipd_version,
)
.await?;
let ctext =
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
Ok(ctext)
}

View File

@@ -1934,6 +1934,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
// We also don't send read receipts for contact requests.
// Read receipts will not be sent even after accepting the chat.
let to_id = if curr_blocked == Blocked::Not
&& !curr_hidden
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?

View File

@@ -1163,17 +1163,6 @@ impl MimeFactory {
_ => None,
};
// Do not anonymize OpenPGP recipients.
//
// This is disabled to avoid interoperability problems
// with old core versions <1.160.0 that do not support
// receiving messages with wildcard Key IDs:
// <https://github.com/chatmail/core/issues/7378>
//
// The option should be changed to true
// once new core versions are sufficiently deployed.
let anonymous_recipients = false;
if context.get_config_bool(Config::TestHooks).await?
&& let Some(hook) = &*context.pre_encrypt_mime_hook.lock()
{
@@ -1211,7 +1200,6 @@ impl MimeFactory {
encryption_keyring,
message,
compress,
anonymous_recipients,
seipd_version,
)
.await?

View File

@@ -151,7 +151,6 @@ pub async fn pk_encrypt(
public_keys_for_encryption: Vec<SignedPublicKey>,
private_key_for_signing: SignedSecretKey,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
Handle::current()
@@ -198,11 +197,7 @@ pub async fn pk_encrypt(
let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM);
for pkey in pkeys {
if anonymous_recipients {
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
} else {
msg.encrypt_to_key(&mut rng, &pkey)?;
}
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
}
let hash_algorithm = private_key_for_signing.hash_alg();
@@ -227,11 +222,7 @@ pub async fn pk_encrypt(
);
for pkey in pkeys {
if anonymous_recipients {
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
} else {
msg.encrypt_to_key(&mut rng, &pkey)?;
}
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
}
let hash_algorithm = private_key_for_signing.hash_alg();
@@ -707,7 +698,6 @@ mod tests {
/// A ciphertext encrypted to Alice & Bob, signed by Alice.
async fn ctext_signed() -> &'static String {
let anonymous_recipients = true;
CTEXT_SIGNED
.get_or_init(|| async {
let keyring = vec![KEYS.alice_public.clone(), KEYS.bob_public.clone()];
@@ -718,7 +708,6 @@ mod tests {
keyring,
KEYS.alice_secret.clone(),
compress,
anonymous_recipients,
SeipdVersion::V2,
)
.await
@@ -905,12 +894,12 @@ mod tests {
let pk_for_encryption = load_self_public_key(alice).await?;
// Encrypt a message, but only to self, not to Bob:
let compress = true;
let ctext = pk_encrypt(
plain,
vec![pk_for_encryption],
KEYS.alice_secret.clone(),
true,
true,
compress,
SeipdVersion::V2,
)
.await?;

View File

@@ -393,7 +393,9 @@ mod tests {
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::contact::{Contact, Origin};
use crate::message::{MessageState, Viewtype, delete_msgs};
use crate::key::{load_self_public_key, load_self_secret_key};
use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs};
use crate::pgp::{SeipdVersion, pk_encrypt};
use crate::receive_imf::receive_imf;
use crate::sql::housekeeping;
use crate::test_utils::E2EE_INFO_MSGS;
@@ -956,4 +958,152 @@ Content-Disposition: reaction\n\
}
Ok(())
}
/// Tests that if reaction requests a read receipt,
/// no read receipt is sent when the chat is marked as noticed.
///
/// Reactions create hidden messages in the chat,
/// and when marking the chat as noticed marks
/// such messages as seen, read receipts should never be sent
/// to avoid the sender of reaction from learning
/// that receiver opened the chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_request_mdn() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat_id(bob).await;
let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
let bob_msg = bob.recv_msg(&alice_sent_msg).await;
bob_msg.chat_id.accept(bob).await?;
assert_eq!(bob_msg.state, MessageState::InFresh);
let bob_chat_id = bob_msg.chat_id;
bob_chat_id.accept(bob).await?;
markseen_msgs(bob, vec![bob_msg.id]).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
1
);
bob.sql.execute("DELETE FROM smtp_mdns", ()).await?;
// Construct reaction with an MDN request.
// Note the `Chat-Disposition-Notification-To` header.
let known_id = bob_msg.rfc724_mid;
let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost";
let plain_text = format!(
"Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r
hp=\"cipher\"\r
Content-Disposition: reaction\r
From: \"Alice\" <alice@example.org>\r
To: \"Bob\" <bob@example.net>\r
Subject: Message from Alice\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Chat-Version: 1.0\r
Chat-Disposition-Notification-To: alice@example.org\r
Message-ID: <{new_id}>\r
HP-Outer: From: <alice@example.org>\r
HP-Outer: To: \"hidden-recipients\": ;\r
HP-Outer: Subject: [...]\r
HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r
HP-Outer: Message-ID: <{new_id}>\r
HP-Outer: In-Reply-To: <{known_id}>\r
HP-Outer: References: <{known_id}>\r
HP-Outer: Chat-Version: 1.0\r
Content-Transfer-Encoding: base64\r
\r
8J+RgA==\r
"
);
let alice_public_key = load_self_public_key(alice).await?;
let bob_public_key = load_self_public_key(bob).await?;
let alice_secret_key = load_self_secret_key(alice).await?;
let public_keys_for_encryption = vec![alice_public_key, bob_public_key];
let compress = true;
let encrypted_payload = pk_encrypt(
plain_text.as_bytes().to_vec(),
public_keys_for_encryption,
alice_secret_key,
compress,
SeipdVersion::V2,
)
.await?;
let boundary = "boundary123";
let rcvd_mail = format!(
"From: <alice@example.org>\r
To: \"hidden-recipients\": ;\r
Subject: [...]\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
Message-ID: <{new_id}>\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r
boundary=\"{boundary}\"\r
MIME-Version: 1.0\r
\r
--{boundary}\r
Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r
Content-Description: PGP/MIME version identification\r
Content-Transfer-Encoding: 7bit\r
\r
Version: 1\r
\r
--{boundary}\r
Content-Type: application/octet-stream; name=\"encrypted.asc\";\r
charset=\"utf-8\"\r
Content-Description: OpenPGP encrypted message\r
Content-Disposition: inline; filename=\"encrypted.asc\";\r
Content-Transfer-Encoding: 7bit\r
\r
{encrypted_payload}
--{boundary}--\r
"
);
let received = receive_imf(bob, rcvd_mail.as_bytes(), false)
.await?
.unwrap();
let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
.await
.unwrap();
assert!(bob_hidden_msg.hidden);
assert_eq!(bob_hidden_msg.chat_id, bob_chat_id);
// Bob does not see new message and cannot mark it as seen directly,
// but can mark the chat as noticed when opening it.
marknoticed_chat(bob, bob_chat_id).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
0,
"Bob should not send MDN to Alice"
);
// MDN request was ignored, but reaction was not.
let reactions = get_msg_reactions(bob, bob_msg.id).await?;
assert_eq!(reactions.reactions.len(), 1);
assert_eq!(
reactions.emoji_sorted_by_frequency(),
vec![("👀".to_string(), 1)]
);
Ok(())
}
}