mirror of
https://github.com/chatmail/core.git
synced 2026-05-02 04:46:29 +03:00
feat: Securejoin v3, encrypt all securejoin messages (#7754)
Close https://github.com/chatmail/core/issues/7396. Before reviewing,
you should read the issue description of
https://github.com/chatmail/core/issues/7396.
I recommend to review with hidden whitespace changes.
TODO:
- [x] Implement the new protocol
- [x] Make Rust tests pass
- [x] Make Python tests pass
- [x] Test it manually on a phone
- [x] Print the sent messages, and check that they look how they should:
[test_secure_join_group_with_mime_printed.txt](https://github.com/user-attachments/files/24800556/test_secure_join_group.txt)
- [x] Fix bug: If Alice has a second device, then Bob's chat won't be
shown yet on that second device. Also, Bob's contact isn't shown in her
contact list. As soon as either party writes something into the chat,
the that shows up and everything is fine. All of this is still a way
better UX than in WhatsApp, where Bob always has to write first 😂
Still, I should fix that.
- This is actually caused by a larger bug: AUTH tokens aren't synced if
there is no corresponding INVITE token.
- Fixed by 6b658a0e0
- [x] Either make a new `auth_tokens` table with a proper UNIQUE bound,
or put a UNIQUE bound on the `tokens` table
- [x] Benchmarking
- [x] TODOs in the code, maybe change naming of the new functions
- [x] Write test for interop with older DC (esp. that the original
securejoin runs if you remove the &v=3 param)
- [x] From a cryptography perspective, is it fine that vc-request is
encrypted with AUTH, rather than a separate secret (like INVITE)?
- [x] Make sure that QR codes without INVITE work, so that we can remove
it eventually
- [x] Self-review, and comment on some of my code changes to explain
what they do
- [x] ~~Maybe use a new table rather than reusing AUTH token.~~ See
https://github.com/chatmail/core/pull/7754#discussion_r2728544725
- [ ] Update documentation; I'll do that in a separate PR. All necessary
information is in the https://github.com/chatmail/core/issues/7396 issue
description
- [ ] Update tests and other code to use the new names (e.g.
`request-pubkey` rather than `request` and `pubkey` rather than
`auth-required`); I'll do that in a follow-up PR
**Backwards compatibility:**
Everything works seamlessly in my tests. If both devices are updated,
then the new protocol is used; otherwise, the old protocol is used. If
there is a not-yet-updated second device, it will correctly observe the
protocol, and mark the chat partner as verified.
Note that I removed the `Auto-Submitted: auto-replied` header from
securejoin messages. We don't need it ourselves, it's a cleartext header
that leaks too much information, and I can't see any reason to have it.
---------
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
This commit is contained in:
@@ -871,16 +871,6 @@ impl MimeFactory {
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
} else if let Loaded::Message { msg, .. } = &self.loaded
|
||||
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
{
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
if step != "vg-request" && step != "vc-request" {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Loaded::Message { msg, chat } = &self.loaded
|
||||
@@ -949,6 +939,22 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_encrypted = self.will_be_encrypted();
|
||||
|
||||
// Add ephemeral timer for non-MDN messages.
|
||||
@@ -995,189 +1001,29 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => self.render_mdn()?,
|
||||
};
|
||||
|
||||
// Split headers based on header confidentiality policy.
|
||||
|
||||
// Headers that must go into IMF header section.
|
||||
//
|
||||
// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
// placed here.
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Headers that MUST NOT (only) go into IMF header section:
|
||||
// - Large headers which may hit the header section size limit on the server, such as
|
||||
// Chat-User-Avatar with a base64-encoded image inside.
|
||||
// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
// Message-ID.
|
||||
// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
// known headers.
|
||||
//
|
||||
// The header should be hidden from MTA
|
||||
// by moving it either into protected part
|
||||
// in case of encrypted mails
|
||||
// or unprotected MIME preamble in case of unencrypted mails.
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Opportunistically protected headers.
|
||||
//
|
||||
// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
//
|
||||
// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
// sure that the message will be encrypted if you place any sensitive information here.
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
unprotected_headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
protected_headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, self.from_addr.clone()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
} else {
|
||||
bail!("Message is unecrypted, cannot include broadcast secret");
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = self.timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"in-reply-to"
|
||||
| "references"
|
||||
| "auto-submitted"
|
||||
| "chat-version"
|
||||
| "autocrypt-setup-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
let HeadersByConfidentiality {
|
||||
mut unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&self.from_addr,
|
||||
self.timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let use_std_header_protection = context
|
||||
.get_config_bool(Config::StdHeaderProtectionComposing)
|
||||
.await?;
|
||||
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
let mut message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Add gossip headers in chats with multiple recipients
|
||||
let multiple_recipients =
|
||||
@@ -1268,21 +1114,6 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message.
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
@@ -1330,8 +1161,9 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
let encrypted = if let Some(shared_secret) = shared_secret {
|
||||
let sign = true;
|
||||
encrypt_helper
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress)
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress, sign)
|
||||
.await?
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
@@ -1365,35 +1197,7 @@ impl MimeFactory {
|
||||
.await?
|
||||
};
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
wrap_encrypted_part(encrypted)
|
||||
} else if matches!(self.loaded, Loaded::Mdn { .. }) {
|
||||
// Never add outer multipart/mixed wrapper to MDN
|
||||
// as multipart/report Content-Type is used to recognize MDNs
|
||||
@@ -1460,22 +1264,12 @@ impl MimeFactory {
|
||||
}
|
||||
};
|
||||
|
||||
// Store the unprotected headers on the outer message.
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let MimeFactory {
|
||||
last_added_location_id,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
let message = String::from_utf8_lossy(&buffer).to_string();
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(RenderedEmail {
|
||||
message,
|
||||
@@ -2137,6 +1931,263 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the unprotected headers on the outer message, and renders it.
|
||||
fn render_outer_message(
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
outer_message: MimePart<'static>,
|
||||
) -> String {
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
String::from_utf8_lossy(&buffer).to_string()
|
||||
}
|
||||
|
||||
/// Takes the encrypted part, wraps it in a MimePart,
|
||||
/// and sets the appropriate Content-Type for the outer message
|
||||
fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn add_headers_to_encrypted_part(
|
||||
message: MimePart<'static>,
|
||||
unprotected_headers: &[(&'static str, HeaderType<'static>)],
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
use_std_header_protection: bool,
|
||||
) -> MimePart<'static> {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
struct HeadersByConfidentiality {
|
||||
/// Headers that must go into IMF header section.
|
||||
///
|
||||
/// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
/// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
/// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
/// placed here.
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Headers that MUST NOT (only) go into IMF header section:
|
||||
/// - Large headers which may hit the header section size limit on the server, such as
|
||||
/// Chat-User-Avatar with a base64-encoded image inside.
|
||||
/// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
/// Message-ID.
|
||||
/// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
/// known headers.
|
||||
///
|
||||
/// The header should be hidden from MTA
|
||||
/// by moving it either into protected part
|
||||
/// in case of encrypted mails
|
||||
/// or unprotected MIME preamble in case of unencrypted mails.
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Opportunistically protected headers.
|
||||
///
|
||||
/// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
/// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
/// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
///
|
||||
/// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
/// sure that the message will be encrypted if you place any sensitive information here.
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
}
|
||||
|
||||
/// Split headers based on header confidentiality policy.
|
||||
/// See [`HeadersByConfidentiality`] for more info.
|
||||
fn group_headers_by_confidentiality(
|
||||
headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
from_addr: &str,
|
||||
timestamp: i64,
|
||||
is_encrypted: bool,
|
||||
is_securejoin_message: bool,
|
||||
) -> HeadersByConfidentiality {
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, from_addr.to_string()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"in-reply-to"
|
||||
| "references"
|
||||
| "auto-submitted"
|
||||
| "chat-version"
|
||||
| "autocrypt-setup-message"
|
||||
| "chat-is-post-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
@@ -2244,5 +2295,114 @@ fn b_encode(value: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn render_symm_encrypted_securejoin_message(
|
||||
context: &Context,
|
||||
step: &str,
|
||||
rfc724_mid: &str,
|
||||
attach_self_pubkey: bool,
|
||||
auth: &str,
|
||||
) -> Result<String> {
|
||||
info!(context, "Sending secure-join message {step:?}.");
|
||||
|
||||
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
|
||||
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let from = new_address_with_name("", from_addr.to_string());
|
||||
headers.push(("From", from.into()));
|
||||
|
||||
let to: Vec<Address<'static>> = vec![hidden_recipients()];
|
||||
headers.push((
|
||||
"To",
|
||||
mail_builder::headers::address::Address::new_list(to.clone()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
|
||||
));
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
|
||||
|
||||
headers.push((
|
||||
"Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(),
|
||||
));
|
||||
|
||||
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
|
||||
if context.get_config_bool(Config::Bot).await? {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
}
|
||||
|
||||
let encrypt_helper = EncryptHelper::new(context).await?;
|
||||
|
||||
if attach_self_pubkey {
|
||||
let aheader = encrypt_helper.get_aheader().to_string();
|
||||
headers.push((
|
||||
"Autocrypt",
|
||||
mail_builder::headers::raw::Raw::new(aheader).into(),
|
||||
));
|
||||
}
|
||||
|
||||
headers.push((
|
||||
"Secure-Join",
|
||||
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Secure-Join-Auth",
|
||||
mail_builder::headers::text::Text::new(auth.to_string()).into(),
|
||||
));
|
||||
|
||||
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
|
||||
|
||||
let is_encrypted = true;
|
||||
let is_securejoin_message = true;
|
||||
let HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&from_addr,
|
||||
timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let outer_message = {
|
||||
let use_std_header_protection = true;
|
||||
let message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
let compress = false;
|
||||
// Only sign the message if we attach the pubkey.
|
||||
let sign = attach_self_pubkey;
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt_symmetrically(context, auth, message, compress, sign)
|
||||
.await?;
|
||||
|
||||
wrap_encrypted_part(encrypted)
|
||||
};
|
||||
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mimefactory_tests;
|
||||
|
||||
Reference in New Issue
Block a user