mirror of
https://github.com/chatmail/core.git
synced 2026-05-07 17:06:35 +03:00
fix: Drop messages encrypted with the wrong symmetric secret (#7963)
The tests were originally generated with AI and then reworked.
Follow-up to https://github.com/chatmail/core/pull/7754 (c724e29)
This prevents the following attack:
/// Eve is subscribed to a channel and wants to know whether Alice is also subscribed to it.
/// To achieve this, Eve sends a message to Alice
/// encrypted with the symmetric secret of this broadcast channel.
///
/// If Alice sends an answer (or read receipt),
/// then Eve knows that Alice is in the broadcast channel.
///
/// A similar attack would be possible with auth tokens
/// that are also used to symmetrically encrypt messages.
///
/// To prevent this, a message that was unexpectedly
/// encrypted with a symmetric secret must be dropped.
This commit is contained in:
@@ -3866,14 +3866,20 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
|||||||
.self_fingerprint
|
.self_fingerprint
|
||||||
.take();
|
.take();
|
||||||
|
|
||||||
tcm.section(
|
tcm.section("Alice sends a message, which is trashed");
|
||||||
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
|
|
||||||
);
|
|
||||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||||
let rcvd = bob.recv_msg(&sent).await;
|
bob.recv_msg_trash(&sent).await;
|
||||||
assert_eq!(rcvd.text, "Hi");
|
let EventType::Warning(warning) = bob
|
||||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
.evtracker
|
||||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
warning.contains("This sender is not allowed to encrypt with this secret key"),
|
||||||
|
"Wrong warning: {warning}"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -3942,6 +3948,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
|||||||
let grpid = "grpid";
|
let grpid = "grpid";
|
||||||
|
|
||||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||||
|
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
|
||||||
|
|
||||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||||
let alice_chat_id = create_out_broadcast_ex(
|
let alice_chat_id = create_out_broadcast_ex(
|
||||||
@@ -3965,6 +3972,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
save_broadcast_secret(bob, bob_chat_id, secret).await?;
|
save_broadcast_secret(bob, bob_chat_id, secret).await?;
|
||||||
|
add_to_chat_contacts_table(bob, time(), bob_chat_id, &[bob_alice_contact_id]).await?;
|
||||||
|
|
||||||
let sent = alice
|
let sent = alice
|
||||||
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
||||||
|
|||||||
240
src/decrypt.rs
240
src/decrypt.rs
@@ -4,21 +4,243 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use ::pgp::composed::Message;
|
use anyhow::{Context as _, Result, bail};
|
||||||
use anyhow::Result;
|
|
||||||
use mailparse::ParsedMail;
|
use mailparse::ParsedMail;
|
||||||
|
use pgp::composed::Esk;
|
||||||
|
use pgp::composed::Message;
|
||||||
|
use pgp::composed::PlainSessionKey;
|
||||||
|
use pgp::composed::SignedSecretKey;
|
||||||
|
use pgp::composed::decrypt_session_key_with_password;
|
||||||
|
use pgp::packet::SymKeyEncryptedSessionKey;
|
||||||
|
use pgp::types::Password;
|
||||||
|
use pgp::types::StringToKey;
|
||||||
|
|
||||||
use crate::key::{Fingerprint, SignedPublicKey};
|
use crate::chat::ChatId;
|
||||||
use crate::pgp;
|
use crate::constants::Chattype;
|
||||||
|
use crate::contact::ContactId;
|
||||||
|
use crate::context::Context;
|
||||||
|
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
|
||||||
|
use crate::token::Namespace;
|
||||||
|
|
||||||
pub fn get_encrypted_pgp_message<'a>(mail: &'a ParsedMail<'a>) -> Result<Option<Message<'static>>> {
|
/// Tries to decrypt the message,
|
||||||
|
/// returning a tuple of `(decrypted message, fingerprint)`.
|
||||||
|
///
|
||||||
|
/// If the message wasn't encrypted, returns `Ok(None)`.
|
||||||
|
///
|
||||||
|
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
|
||||||
|
///
|
||||||
|
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
|
||||||
|
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
|
||||||
|
/// If the message is not signed by `fingerprint`, it must be dropped.
|
||||||
|
///
|
||||||
|
/// Otherwise, Eve could send a message to Alice
|
||||||
|
/// encrypted with the symmetric secret of someone else's broadcast channel.
|
||||||
|
/// If Alice sends an answer (or read receipt),
|
||||||
|
/// then Eve would know that Alice is in the broadcast channel.
|
||||||
|
pub(crate) async fn decrypt(
|
||||||
|
context: &Context,
|
||||||
|
mail: &mailparse::ParsedMail<'_>,
|
||||||
|
) -> Result<Option<(Message<'static>, Option<String>)>> {
|
||||||
|
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
|
||||||
|
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let expected_sender_fingerprint: Option<String>;
|
||||||
|
|
||||||
|
let plain = if let Message::Encrypted { esk, .. } = &*msg
|
||||||
|
// We only allow one ESK for symmetrically encrypted messages
|
||||||
|
// to avoid dealing with messages that are encrypted to multiple symmetric keys
|
||||||
|
// or a mix of symmetric and asymmetric keys:
|
||||||
|
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
|
||||||
|
{
|
||||||
|
check_symmetric_encryption(esk)?;
|
||||||
|
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
|
||||||
|
.await
|
||||||
|
.context("decrypt_session_key_symmetrically")?;
|
||||||
|
expected_sender_fingerprint = fingerprint;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||||
|
let plain = msg
|
||||||
|
.decrypt_with_session_key(psk)
|
||||||
|
.context("decrypt_with_session_key")?;
|
||||||
|
|
||||||
|
let plain: Message<'static> = plain.decompress()?;
|
||||||
|
Ok(plain)
|
||||||
|
})
|
||||||
|
.await??
|
||||||
|
} else {
|
||||||
|
// Message is asymmetrically encrypted
|
||||||
|
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
|
||||||
|
expected_sender_fingerprint = None;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||||
|
let empty_pw = Password::empty();
|
||||||
|
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
|
||||||
|
let plain = msg
|
||||||
|
.decrypt_with_keys(vec![&empty_pw], secret_keys)
|
||||||
|
.context("decrypt_with_keys")?;
|
||||||
|
|
||||||
|
let plain: Message<'static> = plain.decompress()?;
|
||||||
|
Ok(plain)
|
||||||
|
})
|
||||||
|
.await??
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some((plain, expected_sender_fingerprint)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decrypt_session_key_symmetrically(
|
||||||
|
context: &Context,
|
||||||
|
esk: &SymKeyEncryptedSessionKey,
|
||||||
|
) -> Result<(PlainSessionKey, Option<String>)> {
|
||||||
|
let query_only = true;
|
||||||
|
context
|
||||||
|
.sql
|
||||||
|
.call(query_only, |conn| {
|
||||||
|
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
|
||||||
|
// because usually there will only be 1 or 2 of it, so, it should be fast
|
||||||
|
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
|
||||||
|
if let Some((plain_session_key, fingerprint)) = res {
|
||||||
|
return Ok((plain_session_key, Some(fingerprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, try decrypting using broadcast secrets
|
||||||
|
let res: Option<(PlainSessionKey, Option<String>)> =
|
||||||
|
try_decrypt_with_broadcast_secret(esk, conn)?;
|
||||||
|
if let Some((plain_session_key, fingerprint)) = res {
|
||||||
|
return Ok((plain_session_key, fingerprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, try decrypting using own AUTH tokens
|
||||||
|
// There can be a lot of AUTH tokens,
|
||||||
|
// because a new one is generated every time a QR code is shown
|
||||||
|
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn)?;
|
||||||
|
if let Some(plain_session_key) = res {
|
||||||
|
return Ok((plain_session_key, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("Could not find symmetric secret for session key")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decrypt_with_bobstate(
|
||||||
|
esk: &SymKeyEncryptedSessionKey,
|
||||||
|
conn: &mut rusqlite::Connection,
|
||||||
|
) -> Result<Option<(PlainSessionKey, String)>> {
|
||||||
|
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
|
||||||
|
let mut rows = stmt.query(())?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let invite: crate::securejoin::QrInvite = row.get(0)?;
|
||||||
|
let authcode = invite.authcode().to_string();
|
||||||
|
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(authcode)) {
|
||||||
|
let fingerprint = invite.fingerprint().hex();
|
||||||
|
return Ok(Some((psk, fingerprint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decrypt_with_broadcast_secret(
|
||||||
|
esk: &SymKeyEncryptedSessionKey,
|
||||||
|
conn: &mut rusqlite::Connection,
|
||||||
|
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
|
||||||
|
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let chat_type: Chattype =
|
||||||
|
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?;
|
||||||
|
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
|
||||||
|
// An attacker who knows the secret will also know who owns it,
|
||||||
|
// and it's easiest code-wise to just return None here.
|
||||||
|
// But we could alternatively return the self fingerprint here
|
||||||
|
None
|
||||||
|
} else if chat_type == Chattype::InBroadcast {
|
||||||
|
let contact_id: ContactId = conn
|
||||||
|
.query_one(
|
||||||
|
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
|
||||||
|
(chat_id,),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.context("Find InBroadcast owner")?;
|
||||||
|
let fp = conn
|
||||||
|
.query_one(
|
||||||
|
"SELECT fingerprint FROM contacts WHERE id=?",
|
||||||
|
(contact_id,),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.context("Find owner fingerprint")?;
|
||||||
|
Some(fp)
|
||||||
|
} else {
|
||||||
|
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
|
||||||
|
};
|
||||||
|
Ok(Some((psk, fp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decrypt_with_broadcast_secret_inner(
|
||||||
|
esk: &SymKeyEncryptedSessionKey,
|
||||||
|
conn: &mut rusqlite::Connection,
|
||||||
|
) -> Result<Option<(PlainSessionKey, ChatId)>> {
|
||||||
|
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
|
||||||
|
let mut rows = stmt.query(())?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let secret: String = row.get(0)?;
|
||||||
|
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
|
||||||
|
let chat_id: ChatId = row.get(1)?;
|
||||||
|
return Ok(Some((psk, chat_id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decrypt_with_auth_token(
|
||||||
|
esk: &SymKeyEncryptedSessionKey,
|
||||||
|
conn: &mut rusqlite::Connection,
|
||||||
|
) -> Result<Option<PlainSessionKey>> {
|
||||||
|
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
|
||||||
|
// This improves performance when Bob scans a QR code that was just created.
|
||||||
|
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
|
||||||
|
let mut rows = stmt.query((Namespace::Auth,))?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let token: String = row.get(0)?;
|
||||||
|
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(token)) {
|
||||||
|
return Ok(Some(psk));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||||
|
/// and Err with a reason if symmetric decryption should not be tried.
|
||||||
|
///
|
||||||
|
/// A DoS attacker could send a message with a lot of encrypted session keys,
|
||||||
|
/// all of which use a very hard-to-compute string2key algorithm.
|
||||||
|
/// We would then try to decrypt all of the encrypted session keys
|
||||||
|
/// with all of the known shared secrets.
|
||||||
|
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||||
|
/// that use a string2key algorithm other than 'Salted'.
|
||||||
|
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
|
||||||
|
match esk.s2k() {
|
||||||
|
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||||
|
_ => bail!("unsupported string2key algorithm"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
|
||||||
|
/// [`pgp::composed::Message`] is huge (over 4kb),
|
||||||
|
/// so, it is put on the heap using [`Box`].
|
||||||
|
pub fn get_encrypted_pgp_message_boxed<'a>(
|
||||||
|
mail: &'a ParsedMail<'a>,
|
||||||
|
) -> Result<Option<Box<Message<'static>>>> {
|
||||||
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
|
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let data = encrypted_data_part.get_body_raw()?;
|
let data = encrypted_data_part.get_body_raw()?;
|
||||||
let cursor = Cursor::new(data);
|
let cursor = Cursor::new(data);
|
||||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||||
Ok(Some(msg))
|
Ok(Some(Box::new(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a reference to the encrypted payload of a message.
|
/// Returns a reference to the encrypted payload of a message.
|
||||||
@@ -125,8 +347,10 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
|
|||||||
// First part is the content, second part is the signature.
|
// First part is the content, second part is the signature.
|
||||||
let content = first_part.raw_bytes;
|
let content = first_part.raw_bytes;
|
||||||
let ret_valid_signatures = match second_part.get_body_raw() {
|
let ret_valid_signatures = match second_part.get_body_raw() {
|
||||||
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
Ok(signature) => {
|
||||||
.unwrap_or_default(),
|
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
Err(_) => Default::default(),
|
Err(_) => Default::default(),
|
||||||
};
|
};
|
||||||
Some((first_part, ret_valid_signatures))
|
Some((first_part, ret_valid_signatures))
|
||||||
|
|||||||
@@ -1958,7 +1958,7 @@ impl MimeFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the unprotected headers on the outer message, and renders it.
|
/// Stores the unprotected headers on the outer message, and renders it.
|
||||||
fn render_outer_message(
|
pub(crate) fn render_outer_message(
|
||||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||||
outer_message: MimePart<'static>,
|
outer_message: MimePart<'static>,
|
||||||
) -> String {
|
) -> String {
|
||||||
@@ -1976,7 +1976,7 @@ fn render_outer_message(
|
|||||||
|
|
||||||
/// Takes the encrypted part, wraps it in a MimePart,
|
/// Takes the encrypted part, wraps it in a MimePart,
|
||||||
/// and sets the appropriate Content-Type for the outer message
|
/// and sets the appropriate Content-Type for the outer message
|
||||||
fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
|
pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
|
||||||
// XXX: additional newline is needed
|
// XXX: additional newline is needed
|
||||||
// to pass filtermail at
|
// to pass filtermail at
|
||||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::path::Path;
|
|||||||
use std::str;
|
use std::str;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail, ensure};
|
||||||
use deltachat_contact_tools::{addr_cmp, addr_normalize, sanitize_bidi_characters};
|
use deltachat_contact_tools::{addr_cmp, addr_normalize, sanitize_bidi_characters};
|
||||||
use deltachat_derive::{FromSql, ToSql};
|
use deltachat_derive::{FromSql, ToSql};
|
||||||
use format_flowed::unformat_flowed;
|
use format_flowed::unformat_flowed;
|
||||||
@@ -18,14 +18,15 @@ use crate::authres::handle_authres;
|
|||||||
use crate::blob::BlobObject;
|
use crate::blob::BlobObject;
|
||||||
use crate::chat::ChatId;
|
use crate::chat::ChatId;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::constants;
|
||||||
use crate::contact::ContactId;
|
use crate::contact::ContactId;
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::decrypt::{get_encrypted_pgp_message, validate_detached_signature};
|
use crate::decrypt::{self, validate_detached_signature};
|
||||||
use crate::dehtml::dehtml;
|
use crate::dehtml::dehtml;
|
||||||
use crate::download::PostMsgMetadata;
|
use crate::download::PostMsgMetadata;
|
||||||
use crate::events::EventType;
|
use crate::events::EventType;
|
||||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||||
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey, load_self_secret_keyring};
|
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey};
|
||||||
use crate::log::warn;
|
use crate::log::warn;
|
||||||
use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_failed};
|
use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_failed};
|
||||||
use crate::param::{Param, Params};
|
use crate::param::{Param, Params};
|
||||||
@@ -35,7 +36,6 @@ use crate::tools::{
|
|||||||
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
||||||
};
|
};
|
||||||
use crate::{chatlist_events, location, tools};
|
use crate::{chatlist_events, location, tools};
|
||||||
use crate::{constants, token};
|
|
||||||
|
|
||||||
/// Public key extracted from `Autocrypt-Gossip`
|
/// Public key extracted from `Autocrypt-Gossip`
|
||||||
/// header with associated information.
|
/// header with associated information.
|
||||||
@@ -363,7 +363,6 @@ impl MimeMessage {
|
|||||||
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
|
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
|
||||||
|
|
||||||
let mut from = from.context("No from in message")?;
|
let mut from = from.context("No from in message")?;
|
||||||
let private_keyring = load_self_secret_keyring(context).await?;
|
|
||||||
|
|
||||||
let dkim_results = handle_authres(context, &mail, &from.addr).await?;
|
let dkim_results = handle_authres(context, &mail, &from.addr).await?;
|
||||||
|
|
||||||
@@ -385,24 +384,12 @@ impl MimeMessage {
|
|||||||
PreMessageMode::None
|
PreMessageMode::None
|
||||||
};
|
};
|
||||||
|
|
||||||
let encrypted_pgp_message = get_encrypted_pgp_message(&mail)?;
|
|
||||||
|
|
||||||
let secrets: Vec<String>;
|
|
||||||
if let Some(e) = &encrypted_pgp_message
|
|
||||||
&& crate::pgp::check_symmetric_encryption(e).is_ok()
|
|
||||||
{
|
|
||||||
secrets = load_shared_secrets(context).await?;
|
|
||||||
} else {
|
|
||||||
secrets = vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mail_raw; // Memory location for a possible decrypted message.
|
let mail_raw; // Memory location for a possible decrypted message.
|
||||||
let decrypted_msg; // Decrypted signed OpenPGP message.
|
let decrypted_msg; // Decrypted signed OpenPGP message.
|
||||||
|
let expected_sender_fingerprint: Option<String>;
|
||||||
|
|
||||||
let (mail, is_encrypted) = match tokio::task::block_in_place(|| {
|
let (mail, is_encrypted) = match decrypt::decrypt(context, &mail).await {
|
||||||
encrypted_pgp_message.map(|e| crate::pgp::decrypt(e, &private_keyring, &secrets))
|
Ok(Some((mut msg, expected_sender_fp))) => {
|
||||||
}) {
|
|
||||||
Some(Ok(mut msg)) => {
|
|
||||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||||
|
|
||||||
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
||||||
@@ -429,16 +416,19 @@ impl MimeMessage {
|
|||||||
aheader_values = protected_aheader_values;
|
aheader_values = protected_aheader_values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expected_sender_fingerprint = expected_sender_fp;
|
||||||
(Ok(decrypted_mail), true)
|
(Ok(decrypted_mail), true)
|
||||||
}
|
}
|
||||||
None => {
|
Ok(None) => {
|
||||||
mail_raw = Vec::new();
|
mail_raw = Vec::new();
|
||||||
decrypted_msg = None;
|
decrypted_msg = None;
|
||||||
|
expected_sender_fingerprint = None;
|
||||||
(Ok(mail), false)
|
(Ok(mail), false)
|
||||||
}
|
}
|
||||||
Some(Err(err)) => {
|
Err(err) => {
|
||||||
mail_raw = Vec::new();
|
mail_raw = Vec::new();
|
||||||
decrypted_msg = None;
|
decrypted_msg = None;
|
||||||
|
expected_sender_fingerprint = None;
|
||||||
warn!(context, "decryption failed: {:#}", err);
|
warn!(context, "decryption failed: {:#}", err);
|
||||||
(Err(err), false)
|
(Err(err), false)
|
||||||
}
|
}
|
||||||
@@ -552,6 +542,22 @@ impl MimeMessage {
|
|||||||
signatures.extend(signatures_detached);
|
signatures.extend(signatures_detached);
|
||||||
content
|
content
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(expected_sender_fingerprint) = expected_sender_fingerprint {
|
||||||
|
ensure!(
|
||||||
|
!signatures.is_empty(),
|
||||||
|
"Unsigned message is not allowed to be encrypted with this shared secret"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
signatures.len() == 1,
|
||||||
|
"Too many signatures on symm-encrypted message"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
signatures.contains_key(&expected_sender_fingerprint.parse()?),
|
||||||
|
"This sender is not allowed to encrypt with this secret key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if let (Ok(mail), true) = (mail, is_encrypted) {
|
if let (Ok(mail), true) = (mail, is_encrypted) {
|
||||||
if !signatures.is_empty() {
|
if !signatures.is_empty() {
|
||||||
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
|
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
|
||||||
@@ -2110,35 +2116,6 @@ impl MimeMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads all the shared secrets
|
|
||||||
/// that will be tried to decrypt a symmetrically-encrypted message
|
|
||||||
async fn load_shared_secrets(context: &Context) -> Result<Vec<String>> {
|
|
||||||
// First, try decrypting using the bobstate,
|
|
||||||
// because usually there will only be 1 or 2 of it,
|
|
||||||
// so, it should be fast
|
|
||||||
let mut secrets: Vec<String> = context
|
|
||||||
.sql
|
|
||||||
.query_map_vec("SELECT invite FROM bobstate", (), |row| {
|
|
||||||
let invite: crate::securejoin::QrInvite = row.get(0)?;
|
|
||||||
Ok(invite.authcode().to_string())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
// Then, try decrypting using broadcast secrets
|
|
||||||
secrets.extend(
|
|
||||||
context
|
|
||||||
.sql
|
|
||||||
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
|
|
||||||
let secret: String = row.get(0)?;
|
|
||||||
Ok(secret)
|
|
||||||
})
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
// Finally, try decrypting using AUTH tokens
|
|
||||||
// There can be a lot of AUTH tokens, because a new one is generated every time a QR code is shown
|
|
||||||
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
|
|
||||||
Ok(secrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rm_legacy_display_elements(text: &str) -> String {
|
fn rm_legacy_display_elements(text: &str) -> String {
|
||||||
let mut res = None;
|
let mut res = None;
|
||||||
for l in text.lines() {
|
for l in text.lines() {
|
||||||
@@ -2656,3 +2633,5 @@ async fn handle_ndn(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mimeparser_tests;
|
mod mimeparser_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod shared_secret_decryption_tests;
|
||||||
|
|||||||
@@ -2171,9 +2171,6 @@ async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
|
|||||||
()
|
()
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
// This call must not fail:
|
|
||||||
load_shared_secrets(alice).await.unwrap();
|
|
||||||
|
|
||||||
let qr: QrInvite = alice
|
let qr: QrInvite = alice
|
||||||
.sql
|
.sql
|
||||||
.query_get_value("SELECT invite FROM bobstate", ())
|
.query_get_value("SELECT invite FROM bobstate", ())
|
||||||
|
|||||||
242
src/mimeparser/shared_secret_decryption_tests.rs
Normal file
242
src/mimeparser/shared_secret_decryption_tests.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::chat::{create_broadcast, load_broadcast_secret};
|
||||||
|
use crate::constants::DC_CHAT_ID_TRASH;
|
||||||
|
use crate::key::load_self_secret_key;
|
||||||
|
use crate::pgp;
|
||||||
|
use crate::qr::{Qr, check_qr};
|
||||||
|
use crate::receive_imf::receive_imf;
|
||||||
|
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||||
|
use crate::test_utils::{TestContext, TestContextManager};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Tests that the following attack isn't possible:
|
||||||
|
///
|
||||||
|
/// Eve is subscribed to a channel and wants to know whether Alice is also subscribed to it.
|
||||||
|
/// To achieve this, Eve sends a message to Alice
|
||||||
|
/// encrypted with the symmetric secret of this broadcast channel.
|
||||||
|
///
|
||||||
|
/// If Alice sends an answer (or read receipt),
|
||||||
|
/// then Eve knows that Alice is in the broadcast channel.
|
||||||
|
///
|
||||||
|
/// A similar attack would be possible with auth tokens
|
||||||
|
/// that are also used to symmetrically encrypt messages.
|
||||||
|
///
|
||||||
|
/// To defeat this, a message that was unexpectedly
|
||||||
|
/// encrypted with a symmetric secret must be dropped.
|
||||||
|
async fn test_shared_secret_decryption_ex(
|
||||||
|
recipient_ctx: &TestContext,
|
||||||
|
from_addr: &str,
|
||||||
|
secret: &str,
|
||||||
|
signer_ctx: Option<&TestContext>,
|
||||||
|
expected_error: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let plain_body = "Hello, this is a secure message.";
|
||||||
|
let plain_text = format!("Content-Type: text/plain; charset=utf-8\r\n\r\n{plain_body}");
|
||||||
|
let previous_highest_msg_id = get_highest_msg_id(recipient_ctx).await;
|
||||||
|
|
||||||
|
let signer_key = if let Some(signer_ctx) = signer_ctx {
|
||||||
|
Some(load_self_secret_key(signer_ctx).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(signer_ctx) = signer_ctx {
|
||||||
|
// The recipient needs to know the signer's pubkey
|
||||||
|
// in order to be able to validate the pubkey:
|
||||||
|
recipient_ctx.add_or_lookup_contact(signer_ctx).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted_msg =
|
||||||
|
pgp::symm_encrypt_message(plain_text.as_bytes().to_vec(), signer_key, secret, true).await?;
|
||||||
|
|
||||||
|
let boundary = "boundary123";
|
||||||
|
let rcvd_mail = format!(
|
||||||
|
"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"{boundary}\"\n\
|
||||||
|
From: {from}\n\
|
||||||
|
To: \"hidden-recipients\": ;\n\
|
||||||
|
Subject: [...]\n\
|
||||||
|
MIME-Version: 1.0\n\
|
||||||
|
Message-ID: <12345@example.org>\n\
|
||||||
|
\n\
|
||||||
|
--{boundary}\n\
|
||||||
|
Content-Type: application/pgp-encrypted\n\
|
||||||
|
\n\
|
||||||
|
Version: 1\n\
|
||||||
|
\n\
|
||||||
|
--{boundary}\n\
|
||||||
|
Content-Type: application/octet-stream; name=\"encrypted.asc\"\n\
|
||||||
|
Content-Disposition: inline; filename=\"encrypted.asc\"\n\
|
||||||
|
\n\
|
||||||
|
{encrypted_msg}\n\
|
||||||
|
--{boundary}--\n",
|
||||||
|
from = from_addr,
|
||||||
|
boundary = boundary,
|
||||||
|
encrypted_msg = encrypted_msg
|
||||||
|
);
|
||||||
|
|
||||||
|
let rcvd = receive_imf(recipient_ctx, rcvd_mail.as_bytes(), false)
|
||||||
|
.await
|
||||||
|
.expect("If receive_imf() adds an error here, then Bob may be notified about the error and tell the attacker, leaking that he knows the secret")
|
||||||
|
.expect("A trashed message should be created, otherwise we'll unnecessarily download it again");
|
||||||
|
|
||||||
|
if let Some(error_pattern) = expected_error {
|
||||||
|
assert!(rcvd.chat_id == DC_CHAT_ID_TRASH);
|
||||||
|
assert_eq!(
|
||||||
|
previous_highest_msg_id,
|
||||||
|
get_highest_msg_id(recipient_ctx).await,
|
||||||
|
"receive_imf() must not add any message. Otherwise, Bob may send something about an error to the attacker, leaking that he knows the secret"
|
||||||
|
);
|
||||||
|
let EventType::Warning(warning) = recipient_ctx
|
||||||
|
.evtracker
|
||||||
|
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(warning.contains(error_pattern), "Wrong warning: {warning}");
|
||||||
|
} else {
|
||||||
|
let msg = recipient_ctx.get_last_msg().await;
|
||||||
|
assert_eq!(&[msg.id], rcvd.msg_ids.as_slice());
|
||||||
|
assert_eq!(msg.text, plain_body);
|
||||||
|
assert_eq!(rcvd.chat_id.is_special(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_highest_msg_id(context: &Context) -> MsgId {
|
||||||
|
context
|
||||||
|
.sql
|
||||||
|
.query_get_value(
|
||||||
|
"SELECT MAX(id) FROM msgs WHERE chat_id!=?",
|
||||||
|
(DC_CHAT_ID_TRASH,),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_broadcast_security_attacker_signature() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
let charlie = &tcm.charlie().await; // Attacker
|
||||||
|
|
||||||
|
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||||
|
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||||
|
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||||
|
|
||||||
|
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||||
|
|
||||||
|
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(
|
||||||
|
bob,
|
||||||
|
&charlie_addr,
|
||||||
|
&secret,
|
||||||
|
Some(charlie),
|
||||||
|
Some("This sender is not allowed to encrypt with this secret key"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_broadcast_security_no_signature() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
|
||||||
|
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||||
|
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||||
|
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||||
|
|
||||||
|
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(
|
||||||
|
bob,
|
||||||
|
"attacker@example.org",
|
||||||
|
&secret,
|
||||||
|
None,
|
||||||
|
Some("Unsigned message is not allowed to be encrypted with this shared secret"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_broadcast_security_happy_path() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
|
||||||
|
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||||
|
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||||
|
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||||
|
|
||||||
|
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||||
|
|
||||||
|
let alice_addr = alice
|
||||||
|
.get_config(crate::config::Config::Addr)
|
||||||
|
.await?
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(bob, &alice_addr, &secret, Some(alice), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_qr_code_security() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
let charlie = &tcm.charlie().await; // Attacker
|
||||||
|
|
||||||
|
let qr = get_securejoin_qr(bob, None).await?;
|
||||||
|
let Qr::AskVerifyContact { authcode, .. } = check_qr(alice, &qr).await? else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
// Start a securejoin process, but don't finish it:
|
||||||
|
join_securejoin(alice, &qr).await?;
|
||||||
|
|
||||||
|
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(
|
||||||
|
alice,
|
||||||
|
&charlie_addr,
|
||||||
|
&authcode,
|
||||||
|
Some(charlie),
|
||||||
|
Some("This sender is not allowed to encrypt with this secret key"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_qr_code_happy_path() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
|
||||||
|
let qr = get_securejoin_qr(alice, None).await?;
|
||||||
|
let Qr::AskVerifyContact { authcode, .. } = check_qr(bob, &qr).await? else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
// Start a securejoin process, but don't finish it:
|
||||||
|
join_securejoin(bob, &qr).await?;
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(bob, "alice@example.net", &authcode, Some(alice), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control: Test that the behavior is the same when the shared secret is unknown
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_unknown_secret() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
|
||||||
|
test_shared_secret_decryption_ex(
|
||||||
|
bob,
|
||||||
|
"alice@example.net",
|
||||||
|
"Some secret unknown to Bob",
|
||||||
|
Some(alice),
|
||||||
|
Some("Could not find symmetric secret for session key"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
214
src/pgp.rs
214
src/pgp.rs
@@ -3,13 +3,13 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::io::{BufRead, Cursor};
|
use std::io::{BufRead, Cursor};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result};
|
||||||
use deltachat_contact_tools::EmailAddress;
|
use deltachat_contact_tools::EmailAddress;
|
||||||
use pgp::armor::BlockType;
|
use pgp::armor::BlockType;
|
||||||
use pgp::composed::{
|
use pgp::composed::{
|
||||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps,
|
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
|
||||||
KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey,
|
Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey,
|
||||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
|
||||||
};
|
};
|
||||||
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
||||||
use pgp::crypto::ecc_curve::ECCCurve;
|
use pgp::crypto::ecc_curve::ECCCurve;
|
||||||
@@ -293,94 +293,6 @@ pub fn pk_calc_signature(
|
|||||||
Ok(sig.to_armored_string(ArmorOptions::default())?)
|
Ok(sig.to_armored_string(ArmorOptions::default())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypts the message:
|
|
||||||
/// - with keys from the private key keyring (passed in `private_keys_for_decryption`)
|
|
||||||
/// if the message was asymmetrically encrypted,
|
|
||||||
/// - with a shared secret/password (passed in `shared_secrets`),
|
|
||||||
/// if the message was symmetrically encrypted.
|
|
||||||
///
|
|
||||||
/// Returns the decrypted and decompressed message.
|
|
||||||
pub fn decrypt(
|
|
||||||
msg: Message<'static>,
|
|
||||||
private_keys_for_decryption: &[SignedSecretKey],
|
|
||||||
mut shared_secrets: &[String],
|
|
||||||
) -> Result<pgp::composed::Message<'static>> {
|
|
||||||
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
|
|
||||||
let empty_pw = Password::empty();
|
|
||||||
|
|
||||||
let decrypt_options = DecryptionOptions::new();
|
|
||||||
let symmetric_encryption_res = check_symmetric_encryption(&msg);
|
|
||||||
if symmetric_encryption_res.is_err() {
|
|
||||||
shared_secrets = &[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// We always try out all passwords here,
|
|
||||||
// but benchmarking (see `benches/decrypting.rs`)
|
|
||||||
// showed that the performance impact is negligible.
|
|
||||||
// We can improve this in the future if necessary.
|
|
||||||
let message_password: Vec<Password> = shared_secrets
|
|
||||||
.iter()
|
|
||||||
.map(|p| Password::from(p.as_str()))
|
|
||||||
.collect();
|
|
||||||
let message_password: Vec<&Password> = message_password.iter().collect();
|
|
||||||
|
|
||||||
let ring = TheRing {
|
|
||||||
secret_keys: skeys,
|
|
||||||
key_passwords: vec![&empty_pw],
|
|
||||||
message_password,
|
|
||||||
session_keys: vec![],
|
|
||||||
decrypt_options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = msg.decrypt_the_ring(ring, true);
|
|
||||||
|
|
||||||
let (msg, _ring_result) = match res {
|
|
||||||
Ok(it) => it,
|
|
||||||
Err(err) => {
|
|
||||||
if let Err(reason) = symmetric_encryption_res {
|
|
||||||
bail!("{err:#} (Note: symmetric decryption was not tried: {reason})")
|
|
||||||
} else {
|
|
||||||
bail!("{err:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// remove one layer of compression
|
|
||||||
let msg = msg.decompress()?;
|
|
||||||
|
|
||||||
Ok(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
|
||||||
/// and Err with a reason if symmetric decryption should not be tried.
|
|
||||||
///
|
|
||||||
/// A DOS attacker could send a message with a lot of encrypted session keys,
|
|
||||||
/// all of which use a very hard-to-compute string2key algorithm.
|
|
||||||
/// We would then try to decrypt all of the encrypted session keys
|
|
||||||
/// with all of the known shared secrets.
|
|
||||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
|
||||||
/// that use a string2key algorithm other than 'Salted'.
|
|
||||||
pub(crate) fn check_symmetric_encryption(
|
|
||||||
msg: &Message<'_>,
|
|
||||||
) -> std::result::Result<(), &'static str> {
|
|
||||||
let Message::Encrypted { esk, .. } = msg else {
|
|
||||||
return Err("not encrypted");
|
|
||||||
};
|
|
||||||
|
|
||||||
if esk.len() > 1 {
|
|
||||||
return Err("too many esks");
|
|
||||||
}
|
|
||||||
|
|
||||||
let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else {
|
|
||||||
return Err("not symmetrically encrypted");
|
|
||||||
};
|
|
||||||
|
|
||||||
match esk.s2k() {
|
|
||||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
|
||||||
_ => Err("unsupported string2key algorithm"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns fingerprints
|
/// Returns fingerprints
|
||||||
/// of all keys from the `public_keys_for_validation` keyring that
|
/// of all keys from the `public_keys_for_validation` keyring that
|
||||||
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
|
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
|
||||||
@@ -515,24 +427,38 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
key::{load_self_public_key, load_self_secret_key},
|
decrypt,
|
||||||
test_utils::{TestContextManager, alice_keypair, bob_keypair},
|
key::{load_self_public_key, load_self_secret_key, store_self_keypair},
|
||||||
|
mimefactory::{render_outer_message, wrap_encrypted_part},
|
||||||
|
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
|
||||||
|
token,
|
||||||
};
|
};
|
||||||
use pgp::composed::Esk;
|
use pgp::composed::Esk;
|
||||||
use pgp::packet::PublicKeyEncryptedSessionKey;
|
use pgp::packet::PublicKeyEncryptedSessionKey;
|
||||||
|
|
||||||
fn decrypt_bytes(
|
async fn decrypt_bytes(
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
private_keys_for_decryption: &[SignedSecretKey],
|
private_keys_for_decryption: &[SignedSecretKey],
|
||||||
shared_secrets: &[String],
|
shared_secrets: &[String],
|
||||||
) -> Result<pgp::composed::Message<'static>> {
|
) -> Result<pgp::composed::Message<'static>> {
|
||||||
let cursor = Cursor::new(bytes);
|
let t = &TestContext::new().await;
|
||||||
let (msg, _headers) = Message::from_armor(cursor).unwrap();
|
|
||||||
decrypt(msg, private_keys_for_decryption, shared_secrets)
|
for secret in shared_secrets {
|
||||||
|
token::save(t, token::Namespace::Auth, None, secret, 0).await?;
|
||||||
|
}
|
||||||
|
let [secret_key] = private_keys_for_decryption else {
|
||||||
|
panic!("Only one private key is allowed anymore");
|
||||||
|
};
|
||||||
|
store_self_keypair(t, secret_key).await?;
|
||||||
|
|
||||||
|
let mime_message = wrap_encrypted_part(bytes.try_into().unwrap());
|
||||||
|
let rendered = render_outer_message(vec![], mime_message);
|
||||||
|
let parsed = mailparse::parse_mail(rendered.as_bytes())?;
|
||||||
|
let (decrypted, _fp) = decrypt::decrypt(t, &parsed).await?.unwrap();
|
||||||
|
Ok(decrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(clippy::type_complexity)]
|
async fn pk_decrypt_and_validate<'a>(
|
||||||
fn pk_decrypt_and_validate<'a>(
|
|
||||||
ctext: &'a [u8],
|
ctext: &'a [u8],
|
||||||
private_keys_for_decryption: &'a [SignedSecretKey],
|
private_keys_for_decryption: &'a [SignedSecretKey],
|
||||||
public_keys_for_validation: &[SignedPublicKey],
|
public_keys_for_validation: &[SignedPublicKey],
|
||||||
@@ -541,7 +467,7 @@ mod tests {
|
|||||||
HashMap<Fingerprint, Vec<Fingerprint>>,
|
HashMap<Fingerprint, Vec<Fingerprint>>,
|
||||||
Vec<u8>,
|
Vec<u8>,
|
||||||
)> {
|
)> {
|
||||||
let mut msg = decrypt_bytes(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
let mut msg = decrypt_bytes(ctext.to_vec(), private_keys_for_decryption, &[]).await?;
|
||||||
let content = msg.as_data_vec()?;
|
let content = msg.as_data_vec()?;
|
||||||
let ret_signature_fingerprints =
|
let ret_signature_fingerprints =
|
||||||
valid_signature_fingerprints(&msg, public_keys_for_validation);
|
valid_signature_fingerprints(&msg, public_keys_for_validation);
|
||||||
@@ -655,6 +581,7 @@ mod tests {
|
|||||||
&decrypt_keyring,
|
&decrypt_keyring,
|
||||||
&sig_check_keyring,
|
&sig_check_keyring,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(content, CLEARTEXT);
|
assert_eq!(content, CLEARTEXT);
|
||||||
assert_eq!(valid_signatures.len(), 1);
|
assert_eq!(valid_signatures.len(), 1);
|
||||||
@@ -670,6 +597,7 @@ mod tests {
|
|||||||
&decrypt_keyring,
|
&decrypt_keyring,
|
||||||
&sig_check_keyring,
|
&sig_check_keyring,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(content, CLEARTEXT);
|
assert_eq!(content, CLEARTEXT);
|
||||||
assert_eq!(valid_signatures.len(), 1);
|
assert_eq!(valid_signatures.len(), 1);
|
||||||
@@ -682,7 +610,9 @@ mod tests {
|
|||||||
async fn test_decrypt_no_sig_check() {
|
async fn test_decrypt_no_sig_check() {
|
||||||
let keyring = vec![KEYS.alice_secret.clone()];
|
let keyring = vec![KEYS.alice_secret.clone()];
|
||||||
let (_msg, valid_signatures, content) =
|
let (_msg, valid_signatures, content) =
|
||||||
pk_decrypt_and_validate(ctext_signed().await.as_bytes(), &keyring, &[]).unwrap();
|
pk_decrypt_and_validate(ctext_signed().await.as_bytes(), &keyring, &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(content, CLEARTEXT);
|
assert_eq!(content, CLEARTEXT);
|
||||||
assert_eq!(valid_signatures.len(), 0);
|
assert_eq!(valid_signatures.len(), 0);
|
||||||
}
|
}
|
||||||
@@ -697,6 +627,7 @@ mod tests {
|
|||||||
&decrypt_keyring,
|
&decrypt_keyring,
|
||||||
&sig_check_keyring,
|
&sig_check_keyring,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(content, CLEARTEXT);
|
assert_eq!(content, CLEARTEXT);
|
||||||
assert_eq!(valid_signatures.len(), 0);
|
assert_eq!(valid_signatures.len(), 0);
|
||||||
@@ -707,7 +638,9 @@ mod tests {
|
|||||||
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
|
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
|
||||||
let ctext_unsigned = include_bytes!("../test-data/message/ctext_unsigned.asc");
|
let ctext_unsigned = include_bytes!("../test-data/message/ctext_unsigned.asc");
|
||||||
let (_msg, valid_signatures, content) =
|
let (_msg, valid_signatures, content) =
|
||||||
pk_decrypt_and_validate(ctext_unsigned, &decrypt_keyring, &[]).unwrap();
|
pk_decrypt_and_validate(ctext_unsigned, &decrypt_keyring, &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(content, CLEARTEXT);
|
assert_eq!(content, CLEARTEXT);
|
||||||
assert_eq!(valid_signatures.len(), 0);
|
assert_eq!(valid_signatures.len(), 0);
|
||||||
}
|
}
|
||||||
@@ -733,31 +666,65 @@ mod tests {
|
|||||||
ctext.into(),
|
ctext.into(),
|
||||||
&bob_private_keyring,
|
&bob_private_keyring,
|
||||||
&[shared_secret.to_string()],
|
&[shared_secret.to_string()],
|
||||||
)?;
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(decrypted.as_data_vec()?, plain);
|
assert_eq!(decrypted.as_data_vec()?, plain);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_dont_decrypt_expensive_message_happy_path() -> Result<()> {
|
||||||
|
let s2k = StringToKey::Salted {
|
||||||
|
hash_alg: HashAlgorithm::default(),
|
||||||
|
salt: [1; 8],
|
||||||
|
};
|
||||||
|
|
||||||
|
test_dont_decrypt_expensive_message_ex(s2k, false, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_dont_decrypt_expensive_message_bad_s2k() -> Result<()> {
|
||||||
|
let s2k = StringToKey::new_default(&mut thread_rng()); // Default is IteratedAndSalted
|
||||||
|
|
||||||
|
test_dont_decrypt_expensive_message_ex(s2k, false, Some("unsupported string2key algorithm"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_dont_decrypt_expensive_message_multiple_secrets() -> Result<()> {
|
||||||
|
let s2k = StringToKey::Salted {
|
||||||
|
hash_alg: HashAlgorithm::default(),
|
||||||
|
salt: [1; 8],
|
||||||
|
};
|
||||||
|
|
||||||
|
// This error message is actually not great,
|
||||||
|
// but grepping for it will lead to the correct code
|
||||||
|
test_dont_decrypt_expensive_message_ex(s2k, true, Some("decrypt_with_keys: missing key"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Test that we don't try to decrypt a message
|
/// Test that we don't try to decrypt a message
|
||||||
/// that is symmetrically encrypted
|
/// that is symmetrically encrypted
|
||||||
/// with an expensive string2key algorithm
|
/// with an expensive string2key algorithm
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
/// or multiple shared secrets.
|
||||||
async fn test_dont_decrypt_expensive_message() -> Result<()> {
|
/// This is to prevent possible DOS attacks on the app.
|
||||||
|
async fn test_dont_decrypt_expensive_message_ex(
|
||||||
|
s2k: StringToKey,
|
||||||
|
encrypt_twice: bool,
|
||||||
|
expected_error_msg: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
let mut tcm = TestContextManager::new();
|
let mut tcm = TestContextManager::new();
|
||||||
let bob = &tcm.bob().await;
|
let bob = &tcm.bob().await;
|
||||||
|
|
||||||
let plain = Vec::from(b"this is the secret message");
|
let plain = Vec::from(b"this is the secret message");
|
||||||
let shared_secret = "shared secret";
|
let shared_secret = "shared secret";
|
||||||
|
|
||||||
// Create a symmetrically encrypted message
|
|
||||||
// with an IteratedAndSalted string2key algorithm:
|
|
||||||
|
|
||||||
let shared_secret_pw = Password::from(shared_secret.to_string());
|
let shared_secret_pw = Password::from(shared_secret.to_string());
|
||||||
let msg = MessageBuilder::from_bytes("", plain);
|
let msg = MessageBuilder::from_bytes("", plain);
|
||||||
let mut rng = thread_rng();
|
let mut rng = thread_rng();
|
||||||
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
|
|
||||||
|
|
||||||
let mut msg = msg.seipd_v2(
|
let mut msg = msg.seipd_v2(
|
||||||
&mut rng,
|
&mut rng,
|
||||||
@@ -765,24 +732,28 @@ mod tests {
|
|||||||
AeadAlgorithm::Ocb,
|
AeadAlgorithm::Ocb,
|
||||||
ChunkSize::C8KiB,
|
ChunkSize::C8KiB,
|
||||||
);
|
);
|
||||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
msg.encrypt_with_password(&mut rng, s2k.clone(), &shared_secret_pw)?;
|
||||||
|
if encrypt_twice {
|
||||||
|
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
||||||
|
}
|
||||||
|
|
||||||
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
|
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
|
||||||
|
|
||||||
// Trying to decrypt it should fail with a helpful error message:
|
// Trying to decrypt it should fail with a helpful error message:
|
||||||
|
|
||||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||||
let error = decrypt_bytes(
|
let res = decrypt_bytes(
|
||||||
ctext.into(),
|
ctext.into(),
|
||||||
&bob_private_keyring,
|
&bob_private_keyring,
|
||||||
&[shared_secret.to_string()],
|
&[shared_secret.to_string()],
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
if let Some(expected_error_msg) = expected_error_msg {
|
||||||
error.to_string(),
|
assert_eq!(format!("{:#}", res.unwrap_err()), expected_error_msg);
|
||||||
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
|
} else {
|
||||||
);
|
res.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -809,12 +780,11 @@ mod tests {
|
|||||||
|
|
||||||
// Trying to decrypt it should fail with an OK error message:
|
// Trying to decrypt it should fail with an OK error message:
|
||||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||||
let error = decrypt_bytes(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
let error = decrypt_bytes(ctext.into(), &bob_private_keyring, &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(format!("{error:#}"), "decrypt_with_keys: missing key");
|
||||||
error.to_string(),
|
|
||||||
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ impl Sql {
|
|||||||
/// otherwise allocates write connection.
|
/// otherwise allocates write connection.
|
||||||
///
|
///
|
||||||
/// Returns the result of the function.
|
/// Returns the result of the function.
|
||||||
async fn call<'a, F, R>(&'a self, query_only: bool, function: F) -> Result<R>
|
pub async fn call<'a, F, R>(&'a self, query_only: bool, function: F) -> Result<R>
|
||||||
where
|
where
|
||||||
F: 'a + FnOnce(&mut Connection) -> Result<R> + Send,
|
F: 'a + FnOnce(&mut Connection) -> Result<R> + Send,
|
||||||
R: Send + 'static,
|
R: Send + 'static,
|
||||||
|
|||||||
16
src/token.rs
16
src/token.rs
@@ -66,22 +66,6 @@ pub async fn lookup(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Looks up all tokens from the given namespace,
|
|
||||||
/// so that they can be used for decrypting a symmetrically-encrypted message.
|
|
||||||
///
|
|
||||||
/// The most-recently saved tokens are returned first.
|
|
||||||
/// This improves performance when Bob scans a QR code that was just created.
|
|
||||||
pub async fn lookup_all(context: &Context, namespace: Namespace) -> Result<Vec<String>> {
|
|
||||||
context
|
|
||||||
.sql
|
|
||||||
.query_map_vec(
|
|
||||||
"SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC",
|
|
||||||
(namespace,),
|
|
||||||
|row| Ok(row.get(0)?),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn lookup_or_new(
|
pub async fn lookup_or_new(
|
||||||
context: &Context,
|
context: &Context,
|
||||||
namespace: Namespace,
|
namespace: Namespace,
|
||||||
|
|||||||
Reference in New Issue
Block a user