diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c5b7cbb..e94d2e788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased ### Changes +- Implemented "Automatic e-mail address Porting" (AEAP). You can + configure a new address in DC now, and when receivers get messages + they will automatically recognize your moving to a new address. #3385 - switch from `async-std` to `tokio` as the async runtime #3449 - upgrade to `pgp@0.8.0` #3467 - add IMAP ID extension support #3468 diff --git a/draft/aeap-mvp.md b/draft/aeap-mvp.md index b081276a2..4cab44a7c 100644 --- a/draft/aeap-mvp.md +++ b/draft/aeap-mvp.md @@ -11,26 +11,49 @@ Changes to the UIs Changes in the core ------------------- -- DONE: We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them. +- [x] We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them. -- DONE: If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address. +- [x] If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address. - don't forget to deduplicate secondary self addresses in case the user switches back and forth between addresses). - The key stays the same. -- No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.) +- [x] No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.) -- When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible). +- [ ] When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible). -- When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this) +- [x] ([#3385](https://github.com/deltachat/deltachat-core-rust/pull/3385)) When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this) AND there is a `Chat-Version` header\ + AND the message is signed correctly + AND the From address is (also) in the encrypted (and therefore signed) headers [[1]](#myfootnote1)\ AND the message timestamp is newer than the contact's `lastseen` (to prevent changing the address back when messages arrive out of order) (this condition is not that important since we will have eventual consistency even without it): Replace the contact in _all_ groups, possibly deduplicate the members list, and add a system message to all of these chats. - Note that we can't simply compare the keys byte-by-byte, since the UID may have changed, or the sender may have rotated the key and signed the new key with the old one. +[1]: Without this check, an attacker could replay a message from Alice to Bob. Then Bob's device would do an AEAP transition from Alice's to the attacker's address, allowing for easier phishing. + +
+More details about this +Suppose Alice sends a message to Evil (or to a group with both Evil and Bob). Evil then forwards the message to Bob, changing the From and To headers (and if necessary Message-Id) and replacing `addr=alice@example.org;` in the autocrypt header with `addr=evil@example.org;`. + +Then Bob's device sees that there is a message which is signed by Alice's key and comes from Evil's address and would do the AEAP transition, i.e. replace Alice with Evil in all groups and show a message "Alice changed their address from alice@example.org to evil@example.org". Disadvantages for Evil are that Bob's message will be shown on Alice's device, possibly creating confusion/suspicion, and that the usual "Setup changed for..." message will be shown the next time Evil sends a message (because Evil doesn't know Alice's private key). + +Possible mitigations: +- if we make the AEAP device message sth. like "Automatically removed alice@example.org and added evil@example.org", then this will create more suspicion, making the phishing harder (we didn't talk about what what the wording should be at all yet). +- Add something similar to replay protection to our Autocrypt implementation. This could be done e.g. by adding a second `From` header to the protected headers. If it's present, the receiver then requires it to be the same as the outer `From`, and if it's not present, we don't do AEAP --> **That's what we implemented** + +Note that usually a mail is signed by a key that has a UID matching the from address. + + That's not mandatory for Autocrypt (and in fact, we just keep the old UID when changing the self address, so with AEAP the UID will actually be different than the from address sometimes) + + https://autocrypt.org/level1.html#openpgp-based-key-data says: + > The content of the user id packet is only decorative + +
+ ### Notes: - We treat protected and non-protected chats the same @@ -97,3 +120,8 @@ Other ----- - The user is responsible that messages to the old address arrive at the new address, for example by configuring the old provider to forward all emails to the new one. + +Notes during implementing +======================== + +- As far as I understand the code, unencrypted messages are unsigned. So, the transition only works if both sides have the other side's key. \ No newline at end of file diff --git a/spec.md b/spec.md index 54893eff7..e2042d6d7 100644 --- a/spec.md +++ b/spec.md @@ -1,6 +1,6 @@ # chat-mail specification -Version: 0.33.0 +Version: 0.34.0 Status: In-progress Format: [Semantic Line Breaks](https://sembr.org/) @@ -474,4 +474,20 @@ as the sending time of the message as indicated by its Date header, or the time of first receipt if that date is in the future or unavailable. +# Transitioning to a new e-mail address (AEAP) + +When receiving a message: +- If the key exists, but belongs to another address +- AND there is a `Chat-Version` header +- AND the message is signed correctly +- AND the From address is (also) in the encrypted (and therefore signed) headers +- AND the message timestamp is newer than the contact's `lastseen` + (to prevent changing the address back when messages arrive out of order) + (this condition is not that important + since we will have eventual consistency even without it): + + Replace the contact in _all_ groups, + possibly deduplicate the members list, + and add a system message to all of these chats. + Copyright © 2017-2021 Delta Chat contributors. diff --git a/src/config.rs b/src/config.rs index 4a38463e5..e5f2863d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -437,9 +437,8 @@ mod tests { use std::string::ToString; use crate::constants; - use crate::receive_imf::receive_imf; use crate::test_utils::TestContext; - use crate::test_utils::TestContextManager; + use num_traits::FromPrimitive; #[test] @@ -557,68 +556,4 @@ mod tests { Ok(()) } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_change_primary_self_addr() -> Result<()> { - let mut tcm = TestContextManager::new().await; - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - // Alice sends a message to Bob - let alice_bob_chat = alice.create_chat(&bob).await; - let sent = alice.send_text(alice_bob_chat.id, "Hi").await; - let bob_msg = bob.recv_msg(&sent).await; - bob_msg.chat_id.accept(&bob).await?; - assert_eq!(bob_msg.text.unwrap(), "Hi"); - - // Alice changes her self address and reconfigures - // (ensure_secret_key_exists() is called during configure) - alice - .set_primary_self_addr("alice@someotherdomain.xyz") - .await?; - crate::e2ee::ensure_secret_key_exists(&alice).await?; - - assert_eq!( - alice.get_primary_self_addr().await?, - "alice@someotherdomain.xyz" - ); - - // Bob sends a message to Alice, encrypting to her previous key - let sent = bob.send_text(bob_msg.chat_id, "hi back").await; - - // Alice set up message forwarding so that she still receives - // the message with her new address - let alice_msg = alice.recv_msg(&sent).await; - assert_eq!(alice_msg.text, Some("hi back".to_string())); - assert_eq!(alice_msg.get_showpadlock(), true); - assert_eq!(alice_msg.chat_id, alice_bob_chat.id); - - // Even if Bob sends a message to Alice without In-Reply-To, - // it's still assigned to the 1:1 chat with Bob and not to - // a group (without secondary addresses, an ad-hoc group - // would be created) - receive_imf( - &alice, - b"From: bob@example.net -To: alice@example.org -Chat-Version: 1.0 -Message-ID: <456@example.com> - -Message w/out In-Reply-To -", - false, - ) - .await?; - - let alice_msg = alice.get_last_msg().await; - - assert_eq!( - alice_msg.text, - Some("Message w/out In-Reply-To".to_string()) - ); - assert_eq!(alice_msg.get_showpadlock(), false); - assert_eq!(alice_msg.chat_id, alice_bob_chat.id); - - Ok(()) - } } diff --git a/src/e2ee.rs b/src/e2ee.rs index daa183fdb..935876d02 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -8,11 +8,13 @@ use num_traits::FromPrimitive; use crate::aheader::{Aheader, EncryptPreference}; use crate::config::Config; +use crate::contact::addr_cmp; use crate::context::Context; use crate::headerdef::HeaderDef; use crate::headerdef::HeaderDefMap; use crate::key::{DcKey, Fingerprint, SignedPublicKey, SignedSecretKey}; use crate::keyring::Keyring; +use crate::log::LogExt; use crate::peerstate::{Peerstate, PeerstateVerifiedStatus}; use crate::pgp; @@ -131,6 +133,56 @@ impl EncryptHelper { } } +/// Applies Autocrypt header to Autocrypt peer state and saves it into the database. +/// +/// If we already know this fingerprint from another contact's peerstate, return that +/// peerstate in order to make AEAP work, but don't save it into the db yet. +/// +/// Returns updated peerstate. +pub(crate) async fn get_autocrypt_peerstate( + context: &Context, + from: &str, + autocrypt_header: Option<&Aheader>, + message_time: i64, +) -> Result> { + let mut peerstate; + + // Apply Autocrypt header + if let Some(header) = autocrypt_header { + // The "from_nongossiped_fingerprint" part is for AEAP: + // If we know this fingerprint from another addr, + // we may want to do a transition from this other addr + // (and keep its peerstate) + peerstate = Peerstate::from_nongossiped_fingerprint_or_addr( + context, + &header.public_key.fingerprint(), + from, + ) + .await?; + + if let Some(ref mut peerstate) = peerstate { + if addr_cmp(&peerstate.addr, from) { + peerstate.apply_header(header, message_time); + peerstate.save_to_db(&context.sql, false).await?; + } + // If `peerstate.addr` and `from` differ, this means that + // someone is using the same key but a different addr, probably + // because they made an AEAP transition. + // But we don't know if that's legit until we checked the + // signatures, so wait until then with writing anything + // to the database. + } else { + let p = Peerstate::from_header(header, message_time); + p.save_to_db(&context.sql, true).await?; + peerstate = Some(p); + } + } else { + peerstate = Peerstate::from_addr(context, from).await?; + } + + Ok(peerstate) +} + /// Tries to decrypt a message, but only if it is structured as an /// Autocrypt message. /// @@ -140,10 +192,42 @@ impl EncryptHelper { /// If the message is wrongly signed, this will still return the decrypted /// message but the HashSet will be empty. pub async fn try_decrypt( + context: &Context, + mail: &ParsedMail<'_>, + decryption_info: &DecryptionInfo, +) -> Result, HashSet)>> { + // Possibly perform decryption + let public_keyring_for_validate = keyring_from_peerstate(&decryption_info.peerstate); + + let context = context; + let encrypted_data_part = match get_autocrypt_mime(mail) + .or_else(|| get_mixed_up_mime(mail)) + .or_else(|| get_attachment_mime(mail)) + { + None => { + // not an autocrypt mime message, abort and ignore + return Ok(None); + } + Some(res) => res, + }; + info!(context, "Detected Autocrypt-mime message"); + let private_keyring: Keyring = Keyring::new_self(context) + .await + .context("failed to get own keyring")?; + + decrypt_part( + encrypted_data_part, + private_keyring, + public_keyring_for_validate, + ) + .await +} + +pub async fn create_decryption_info( context: &Context, mail: &ParsedMail<'_>, message_time: i64, -) -> Result<(Option>, HashSet)> { +) -> Result { let from = mail .headers .get_header(HeaderDef::From_) @@ -152,56 +236,34 @@ pub async fn try_decrypt( .map(|from| from.addr) .unwrap_or_default(); - let mut peerstate = Peerstate::from_addr(context, &from).await?; + let autocrypt_header = Aheader::from_headers(&from, &mail.headers) + .ok_or_log_msg(context, "Failed to parse Autocrypt header") + .flatten(); - // Apply Autocrypt header - match Aheader::from_headers(&from, &mail.headers) { - Ok(Some(ref header)) => { - if let Some(ref mut peerstate) = peerstate { - peerstate.apply_header(header, message_time); - peerstate.save_to_db(&context.sql, false).await?; - } else { - let p = Peerstate::from_header(header, message_time); - p.save_to_db(&context.sql, true).await?; - peerstate = Some(p); - } - } - Ok(None) => {} - Err(err) => warn!(context, "Failed to parse Autocrypt header: {}", err), - } + let peerstate = + get_autocrypt_peerstate(context, &from, autocrypt_header.as_ref(), message_time).await?; - // Possibly perform decryption - let mut public_keyring_for_validate: Keyring = Keyring::new(); + Ok(DecryptionInfo { + from, + autocrypt_header, + peerstate, + message_time, + }) +} - if let Some(ref mut peerstate) = peerstate { - peerstate - .handle_fingerprint_change(context, message_time) - .await?; - if let Some(key) = &peerstate.public_key { - public_keyring_for_validate.add(key.clone()); - } else if let Some(key) = &peerstate.gossip_key { - public_keyring_for_validate.add(key.clone()); - } - } - - let (out_mail, signatures) = - match decrypt_if_autocrypt_message(context, mail, public_keyring_for_validate).await? { - Some((out_mail, signatures)) => (Some(out_mail), signatures), - None => (None, Default::default()), - }; - - if let Some(mut peerstate) = peerstate { - // If message is not encrypted and it is not a read receipt, degrade encryption. - if out_mail.is_none() - && message_time > peerstate.last_seen_autocrypt - && !contains_report(mail) - { - peerstate.degrade_encryption(message_time); - peerstate.save_to_db(&context.sql, false).await?; - } - } - - Ok((out_mail, signatures)) +#[derive(Debug)] +pub struct DecryptionInfo { + /// The From address. This is the address from the unnencrypted, outer + /// From header. + pub from: String, + pub autocrypt_header: Option, + /// The peerstate that will be used to validate the signatures + pub peerstate: Option, + /// The timestamp when the message was sent. + /// If this is older than the peerstate's last_seen, this probably + /// means out-of-order message arrival, We don't modify the + /// peerstate in this case. + pub message_time: i64, } /// Returns a reference to the encrypted payload of a valid PGP/MIME message. @@ -283,32 +345,16 @@ fn get_attachment_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMai } } -async fn decrypt_if_autocrypt_message( - context: &Context, - mail: &ParsedMail<'_>, - public_keyring_for_validate: Keyring, -) -> Result, HashSet)>> { - let encrypted_data_part = match get_autocrypt_mime(mail) - .or_else(|| get_mixed_up_mime(mail)) - .or_else(|| get_attachment_mime(mail)) - { - None => { - // not an autocrypt mime message, abort and ignore - return Ok(None); +fn keyring_from_peerstate(peerstate: &Option) -> Keyring { + let mut public_keyring_for_validate: Keyring = Keyring::new(); + if let Some(ref peerstate) = *peerstate { + if let Some(key) = &peerstate.public_key { + public_keyring_for_validate.add(key.clone()); + } else if let Some(key) = &peerstate.gossip_key { + public_keyring_for_validate.add(key.clone()); } - Some(res) => res, - }; - info!(context, "Detected Autocrypt-mime message"); - let private_keyring: Keyring = Keyring::new_self(context) - .await - .context("failed to get own keyring")?; - - decrypt_part( - encrypted_data_part, - private_keyring, - public_keyring_for_validate, - ) - .await + } + public_keyring_for_validate } /// Validates signatures of Multipart/Signed message part, as defined in RFC 1847. @@ -357,7 +403,7 @@ async fn decrypt_part( return Ok(Some((content, valid_detached_signatures))); } else { // If the message was wrongly or not signed, still return the plain text. - // The caller has to check the signatures then. + // The caller has to check if the signatures set is empty then. return Ok(Some((plain, ret_valid_signatures))); } @@ -380,18 +426,6 @@ fn has_decrypted_pgp_armor(input: &[u8]) -> bool { false } -/// Checks if a MIME structure contains a multipart/report part. -/// -/// As reports are often unencrypted, we do not reset the Autocrypt header in -/// this case. -/// -/// However, Delta Chat itself has no problem with encrypted multipart/report -/// parts and MUAs should be encouraged to encrpyt multipart/reports as well so -/// that we could use the normal Autocrypt processing. -fn contains_report(mail: &ParsedMail<'_>) -> bool { - mail.ctype.mimetype == "multipart/report" -} - /// Ensures a private key exists for the configured user. /// /// Normally the private key is generated when the first message is diff --git a/src/lib.rs b/src/lib.rs index e3c5a0971..12459b3b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -108,3 +108,5 @@ pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG"; #[cfg(test)] mod test_utils; +#[cfg(test)] +mod tests; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 0479f2605..dab72d521 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -496,9 +496,9 @@ impl<'a> MimeFactory<'a> { // Start with Internet Message Format headers in the order of the standard example // . - headers - .unprotected - .push(Header::new_with_value("From".into(), vec![from]).unwrap()); + let from_header = Header::new_with_value("From".into(), vec![from]).unwrap(); + headers.unprotected.push(from_header.clone()); + if let Some(sender_displayname) = &self.sender_displayname { let sender = Address::new_mailbox_with_name(sender_displayname.clone(), self.from_addr.clone()); @@ -666,6 +666,8 @@ impl<'a> MimeFactory<'a> { }; let outer_message = if is_encrypted { + headers.protected.push(from_header); + // Store protected headers in the inner message. let message = headers .protected diff --git a/src/mimeparser.rs b/src/mimeparser.rs index c055aae3a..f9012a3a2 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -13,7 +13,7 @@ use once_cell::sync::Lazy; use crate::aheader::Aheader; use crate::blob::BlobObject; use crate::constants::{DC_DESIRED_TEXT_LEN, DC_ELLIPSIS}; -use crate::contact::{addr_normalize, ContactId}; +use crate::contact::{addr_cmp, addr_normalize, ContactId}; use crate::context::Context; use crate::dehtml::dehtml; use crate::e2ee; @@ -47,6 +47,9 @@ pub struct MimeMessage { /// Addresses are normalized and lowercased: pub recipients: Vec, pub from: Vec, + /// Whether the From address was repeated in the signed part + /// (and we know that the signer intended to send from this address) + pub from_is_signed: bool, pub list_post: Option, pub chat_disposition_notification_to: Option, pub decrypting_failed: bool, @@ -216,67 +219,94 @@ impl MimeMessage { // Memory location for a possible decrypted message. let mut mail_raw = Vec::new(); let mut gossiped_addr = Default::default(); + let mut from_is_signed = false; + let mut decryption_info = + e2ee::create_decryption_info(context, &mail, message_time).await?; + // `signatures` is non-empty exactly if the message was encrypted and correctly signed. let (mail, signatures, warn_empty_signature) = - match e2ee::try_decrypt(context, &mail, message_time).await { - Ok((raw, signatures)) => { - if let Some(raw) = raw { - // Encrypted, but maybe unsigned message. Only if - // `signatures` set is non-empty, it is a valid - // autocrypt message. + match e2ee::try_decrypt(context, &mail, &decryption_info).await { + Ok(Some((raw, signatures))) => { + // Encrypted, but maybe unsigned message. Only if + // `signatures` set is non-empty, it is a valid + // autocrypt message. - mail_raw = raw; - let decrypted_mail = mailparse::parse_mail(&mail_raw)?; - if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { - info!(context, "decrypted message mime-body:"); - println!("{}", String::from_utf8_lossy(&mail_raw)); - } - - // Handle any gossip headers if the mail was encrypted. See section - // "3.6 Key Gossip" of - // but only if the mail was correctly signed: - if !signatures.is_empty() { - let gossip_headers = - decrypted_mail.headers.get_all_values("Autocrypt-Gossip"); - gossiped_addr = update_gossip_peerstates( - context, - message_time, - &mail, - gossip_headers, - ) - .await?; - } - - // let known protected headers from the decrypted - // part override the unencrypted top-level - - // Signature was checked for original From, so we - // do not allow overriding it. - let mut throwaway_from = from.clone(); - - // We do not want to allow unencrypted subject in encrypted emails because the user might falsely think that the subject is safe. - // See . - headers.remove("subject"); - - MimeMessage::merge_headers( - context, - &mut headers, - &mut recipients, - &mut throwaway_from, - &mut list_post, - &mut chat_disposition_notification_to, - &decrypted_mail.headers, - ); - - (Ok(decrypted_mail), signatures, true) - } else { - // Message was not encrypted - (Ok(mail), signatures, false) + mail_raw = raw; + let decrypted_mail = mailparse::parse_mail(&mail_raw)?; + if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { + info!(context, "decrypted message mime-body:"); + println!("{}", String::from_utf8_lossy(&mail_raw)); } + + // Handle any gossip headers if the mail was encrypted. See section + // "3.6 Key Gossip" of + // but only if the mail was correctly signed: + if !signatures.is_empty() { + let gossip_headers = + decrypted_mail.headers.get_all_values("Autocrypt-Gossip"); + gossiped_addr = + update_gossip_peerstates(context, message_time, &mail, gossip_headers) + .await?; + } + + // let known protected headers from the decrypted + // part override the unencrypted top-level + + // Signature was checked for original From, so we + // do not allow overriding it. + let mut signed_from = Vec::new(); + + // We do not want to allow unencrypted subject in encrypted emails because the user might falsely think that the subject is safe. + // See . + headers.remove("subject"); + + MimeMessage::merge_headers( + context, + &mut headers, + &mut recipients, + &mut signed_from, + &mut list_post, + &mut chat_disposition_notification_to, + &decrypted_mail.headers, + ); + if let Some(signed_from) = signed_from.first() { + if let Some(from) = from.first() { + if addr_cmp(&signed_from.addr, &from.addr) { + from_is_signed = true; + } else { + // There is a From: header in the encrypted & + // signed part, but it doesn't match the outer one. + // This _might_ be because the sender's mail server + // replaced the sending address, e.g. in a mailing list. + // Or it's because someone is doing some replay attack + // - OTOH, I can't come up with an attack scenario + // where this would be useful. + warn!( + context, + "From header in signed part does't match the outer one" + ); + } + } + } + + (Ok(decrypted_mail), signatures, true) + } + Ok(None) => { + // Message was not encrypted. + // If it is not a read receipt, degrade encryption. + if let Some(peerstate) = &mut decryption_info.peerstate { + if message_time > peerstate.last_seen_autocrypt + && mail.ctype.mimetype != "multipart/report" + { + peerstate.degrade_encryption(message_time); + peerstate.save_to_db(&context.sql, false).await?; + } + } + (Ok(mail), HashSet::new(), false) } Err(err) => { warn!(context, "decryption failed: {}", err); - (Err(err), Default::default(), true) + (Err(err), HashSet::new(), true) } }; @@ -286,6 +316,7 @@ impl MimeMessage { recipients, list_post, from, + from_is_signed, chat_disposition_notification_to, decrypting_failed: mail.is_err(), @@ -349,6 +380,13 @@ impl MimeMessage { parser.decoded_data = mail_raw; } + crate::peerstate::maybe_do_aeap_transition(context, &mut decryption_info, &parser).await?; + if let Some(peerstate) = decryption_info.peerstate { + peerstate + .handle_fingerprint_change(context, message_time) + .await?; + } + Ok(parser) } diff --git a/src/peerstate.rs b/src/peerstate.rs index 16e9deb01..283a305d7 100644 --- a/src/peerstate.rs +++ b/src/peerstate.rs @@ -4,8 +4,10 @@ use std::collections::HashSet; use std::fmt; use crate::aheader::{Aheader, EncryptPreference}; -use crate::chat::{self}; +use crate::chat::{self, is_contact_in_chat, Chat}; use crate::chatlist::Chatlist; +use crate::constants::Chattype; +use crate::contact::{addr_cmp, Contact, Origin}; use crate::context::Context; use crate::events::EventType; use crate::key::{DcKey, Fingerprint, SignedPublicKey}; @@ -13,7 +15,7 @@ use crate::message::Message; use crate::mimeparser::SystemMessage; use crate::sql::Sql; use crate::stock_str; -use anyhow::{bail, Result}; +use anyhow::{Context as _, Result}; use num_traits::FromPrimitive; #[derive(Debug)] @@ -144,26 +146,41 @@ impl Peerstate { gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ verified_key, verified_key_fingerprint \ FROM acpeerstates \ - WHERE addr=? COLLATE NOCASE;"; + WHERE addr=? COLLATE NOCASE LIMIT 1;"; Self::from_stmt(context, query, paramsv![addr]).await } pub async fn from_fingerprint( context: &Context, - _sql: &Sql, fingerprint: &Fingerprint, ) -> Result> { let query = "SELECT addr, last_seen, last_seen_autocrypt, prefer_encrypted, public_key, \ gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ verified_key, verified_key_fingerprint \ FROM acpeerstates \ - WHERE public_key_fingerprint=? COLLATE NOCASE \ - OR gossip_key_fingerprint=? COLLATE NOCASE \ - ORDER BY public_key_fingerprint=? DESC;"; + WHERE public_key_fingerprint=? \ + OR gossip_key_fingerprint=? \ + ORDER BY public_key_fingerprint=? DESC LIMIT 1;"; let fp = fingerprint.hex(); Self::from_stmt(context, query, paramsv![fp, fp, fp]).await } + pub async fn from_nongossiped_fingerprint_or_addr( + context: &Context, + fingerprint: &Fingerprint, + addr: &str, + ) -> Result> { + let query = "SELECT addr, last_seen, last_seen_autocrypt, prefer_encrypted, public_key, \ + gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ + verified_key, verified_key_fingerprint \ + FROM acpeerstates \ + WHERE public_key_fingerprint=? \ + OR addr=? COLLATE NOCASE \ + ORDER BY public_key_fingerprint=? DESC, last_seen DESC LIMIT 1;"; + let fp = fingerprint.hex(); + Self::from_stmt(context, query, paramsv![fp, addr, fp]).await + } + async fn from_stmt( context: &Context, query: &str, @@ -220,6 +237,10 @@ impl Peerstate { Ok(peerstate) } + /// Re-calculate `self.public_key_fingerprint` and `self.gossip_key_fingerprint`. + /// If one of them was changed, `self.fingerprint_changed` is set to `true`. + /// + /// Call this after you changed `self.public_key` or `self.gossip_key`. pub fn recalc_fingerprint(&mut self) { if let Some(ref public_key) = self.public_key { let old_public_fingerprint = self.public_key_fingerprint.take(); @@ -261,61 +282,8 @@ impl Peerstate { self.to_save = Some(ToSave::All); } - /// Adds a warning to the chat corresponding to peerstate if fingerprint has changed. - pub(crate) async fn handle_fingerprint_change( - &self, - context: &Context, - timestamp: i64, - ) -> Result<()> { - if context.is_self_addr(&self.addr).await? { - // Do not try to search all the chats with self. - return Ok(()); - } - - if self.fingerprint_changed { - if let Some(contact_id) = context - .sql - .query_get_value("SELECT id FROM contacts WHERE addr=?;", paramsv![self.addr]) - .await? - { - let chats = Chatlist::try_load(context, 0, None, contact_id).await?; - let msg = stock_str::contact_setup_changed(context, self.addr.clone()).await; - for (chat_id, msg_id) in chats.iter() { - let timestamp_sort = if let Some(msg_id) = msg_id { - let lastmsg = Message::load_from_db(context, *msg_id).await?; - lastmsg.timestamp_sort - } else { - context - .sql - .query_get_value( - "SELECT created_timestamp FROM chats WHERE id=?;", - paramsv![chat_id], - ) - .await? - .unwrap_or(0) - }; - chat::add_info_msg_with_cmd( - context, - *chat_id, - &msg, - SystemMessage::Unknown, - timestamp_sort, - Some(timestamp), - None, - None, - ) - .await?; - context.emit_event(EventType::ChatModified(*chat_id)); - } - } else { - bail!("contact with peerstate.addr {:?} not found", &self.addr); - } - } - Ok(()) - } - pub fn apply_header(&mut self, header: &Aheader, message_time: i64) { - if self.addr.to_lowercase() != header.addr.to_lowercase() { + if !addr_cmp(&self.addr, &header.addr) { return; } @@ -521,6 +489,175 @@ impl Peerstate { false } } + + /// Add an info message to all the chats with this contact, informing about + /// a [`PeerstateChange`]. + /// + /// Also, in the case of an address change (AEAP), replace the old address + /// with the new address in all chats. + async fn handle_setup_change( + &self, + context: &Context, + timestamp: i64, + change: PeerstateChange, + ) -> Result<()> { + if context.is_self_addr(&self.addr).await? { + // Do not try to search all the chats with self. + return Ok(()); + } + + let contact_id = context + .sql + .query_get_value( + "SELECT id FROM contacts WHERE addr=? COLLATE NOCASE;", + paramsv![self.addr], + ) + .await? + .with_context(|| format!("contact with peerstate.addr {:?} not found", &self.addr))?; + + let chats = Chatlist::try_load(context, 0, None, Some(contact_id)).await?; + for (chat_id, msg_id) in chats.iter() { + let msg = match &change { + PeerstateChange::FingerprintChange => { + stock_str::contact_setup_changed(context, self.addr.clone()).await + } + PeerstateChange::Aeap(new_addr) => { + let old_contact = Contact::load_from_db(context, contact_id).await?; + stock_str::aeap_addr_changed( + context, + old_contact.get_display_name(), + &self.addr, + new_addr, + ) + .await + } + }; + let timestamp_sort = if let Some(msg_id) = msg_id { + let lastmsg = Message::load_from_db(context, *msg_id).await?; + lastmsg.timestamp_sort + } else { + context + .sql + .query_get_value( + "SELECT created_timestamp FROM chats WHERE id=?;", + paramsv![chat_id], + ) + .await? + .unwrap_or(0) + }; + chat::add_info_msg_with_cmd( + context, + *chat_id, + &msg, + SystemMessage::Unknown, + timestamp_sort, + Some(timestamp), + None, + None, + ) + .await?; + + if let PeerstateChange::Aeap(new_addr) = &change { + let chat = Chat::load_from_db(context, *chat_id).await?; + if chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast { + chat::remove_from_chat_contacts_table(context, *chat_id, contact_id).await?; + + let (new_contact_id, _) = + Contact::add_or_lookup(context, "", new_addr, Origin::IncomingUnknownFrom) + .await?; + if !is_contact_in_chat(context, *chat_id, new_contact_id).await? { + chat::add_to_chat_contacts_table(context, *chat_id, new_contact_id).await?; + } + + context.emit_event(EventType::ChatModified(*chat_id)); + } + } + } + + Ok(()) + } + + /// Adds a warning to all the chats corresponding to peerstate if fingerprint has changed. + pub(crate) async fn handle_fingerprint_change( + &self, + context: &Context, + timestamp: i64, + ) -> Result<()> { + if self.fingerprint_changed { + self.handle_setup_change(context, timestamp, PeerstateChange::FingerprintChange) + .await?; + } + Ok(()) + } +} + +/// Do an AEAP transition, if necessary. +/// AEAP stands for "Automatic Email Address Porting." +/// +/// In `drafts/aeap_mvp.md` there is a "big picture" overview over AEAP. +pub async fn maybe_do_aeap_transition( + context: &Context, + info: &mut crate::e2ee::DecryptionInfo, + mime_parser: &crate::mimeparser::MimeMessage, +) -> Result<()> { + if let Some(peerstate) = &mut info.peerstate { + if let Some(from) = mime_parser.from.first() { + // If the from addr is different from the peerstate address we know, + // we may want to do an AEAP transition. + if !addr_cmp(&peerstate.addr, &from.addr) + // Check if it's a chat message; we do this to avoid + // some accidental transitions if someone writes from multiple + // addresses with an MUA. + && mime_parser.has_chat_version() + // Check if the message is signed correctly. + // If it's not signed correctly, the whole autocrypt header will be mostly + // ignored anyway and the message shown as not encrypted, so we don't + // have to handle this case. + && !mime_parser.signatures.is_empty() + // Check if the From: address was also in the signed part of the email. + // Without this check, an attacker could replay a message from Alice + // to Bob. Then Bob's device would do an AEAP transition from Alice's + // to the attacker's address, allowing for easier phishing. + && mime_parser.from_is_signed + && info.message_time > peerstate.last_seen + { + // Add an info messages to all chats with this contact + // + peerstate + .handle_setup_change( + context, + info.message_time, + PeerstateChange::Aeap(info.from.clone()), + ) + .await?; + + peerstate.addr = info.from.clone(); + let header = info.autocrypt_header.as_ref().context( + "Internal error: Tried to do an AEAP transition without an autocrypt header??", + )?; + peerstate.apply_header(header, info.message_time); + peerstate.to_save = Some(ToSave::All); + + // We don't know whether a peerstate with this address already existed, or a + // new one should be created, so just try both create=false and create=true, + // and if this fails, create=true, one will succeed (this is a very cold path, + // so performance doesn't really matter). + peerstate.save_to_db(&context.sql, true).await?; + peerstate.save_to_db(&context.sql, false).await?; + } + } + } + + Ok(()) +} + +enum PeerstateChange { + /// The contact's public key fingerprint changed, likely because + /// the contact uses a new device and didn't transfer their key. + FingerprintChange, + /// The contact changed their address to the given new address + /// (Automatic Email Address Porting). + Aeap(String), } /// Removes duplicate peerstates from `acpeerstates` database table. @@ -588,11 +725,10 @@ mod tests { // clear to_save, as that is not persissted peerstate.to_save = None; assert_eq!(peerstate, peerstate_new); - let peerstate_new2 = - Peerstate::from_fingerprint(&ctx.ctx, &ctx.ctx.sql, &pub_key.fingerprint()) - .await - .expect("failed to load peerstate from db") - .expect("no peerstate found in the database"); + let peerstate_new2 = Peerstate::from_fingerprint(&ctx.ctx, &pub_key.fingerprint()) + .await + .expect("failed to load peerstate from db") + .expect("no peerstate found in the database"); assert_eq!(peerstate, peerstate_new2); } diff --git a/src/qr.rs b/src/qr.rs index 157f66c00..d3693fff8 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -201,7 +201,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { }; // retrieve known state for this fingerprint - let peerstate = Peerstate::from_fingerprint(context, &context.sql, &fingerprint) + let peerstate = Peerstate::from_fingerprint(context, &fingerprint) .await .context("Can't load peerstate")?; diff --git a/src/securejoin.rs b/src/securejoin.rs index 2f573659f..9f25fbb34 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -633,9 +633,7 @@ async fn could_not_establish_secure_connection( } async fn mark_peer_as_verified(context: &Context, fingerprint: &Fingerprint) -> Result<(), Error> { - if let Some(ref mut peerstate) = - Peerstate::from_fingerprint(context, &context.sql, fingerprint).await? - { + if let Some(ref mut peerstate) = Peerstate::from_fingerprint(context, fingerprint).await? { if peerstate.set_verified( PeerstateKeyType::PublicKey, fingerprint, diff --git a/src/stock_str.rs b/src/stock_str.rs index ff5d071c4..deed2c8a6 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -332,6 +332,9 @@ pub enum StockMessage { #[strum(props(fallback = "Not connected"))] NotConnected = 121, + + #[strum(props(fallback = "%1$s changed their address from %2$s to %3$s"))] + AeapAddrChanged = 122, } impl StockMessage { @@ -375,6 +378,17 @@ trait StockStringMods: AsRef + Sized { .replacen("%2$@", replacement.as_ref(), 1) } + /// Substitutes the third replacement value if one is present. + /// + /// Be aware you probably should have also called [`StockStringMods::replace1`] and + /// [`StockStringMods::replace2`] if you are calling this. + fn replace3(&self, replacement: impl AsRef) -> String { + self.as_ref() + .replacen("%3$s", replacement.as_ref(), 1) + .replacen("%3$d", replacement.as_ref(), 1) + .replacen("%3$@", replacement.as_ref(), 1) + } + /// Augments the message by saying it was performed by a user. /// /// This looks up the display name of `contact` and uses the [`msg_action_by_me`] and @@ -1076,6 +1090,20 @@ pub(crate) async fn broadcast_list(context: &Context) -> String { translated(context, StockMessage::BroadcastList).await } +/// Stock string: `%1$s changed their address from %2$s to %3$s`. +pub(crate) async fn aeap_addr_changed( + context: &Context, + contact_name: impl AsRef, + old_addr: impl AsRef, + new_addr: impl AsRef, +) -> String { + translated(context, StockMessage::AeapAddrChanged) + .await + .replace1(contact_name) + .replace2(old_addr) + .replace3(new_addr) +} + impl Context { /// Set the stock string for the [StockMessage]. /// diff --git a/src/test_utils.rs b/src/test_utils.rs index 710052cb5..2b3b92983 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -2,7 +2,6 @@ //! //! This private module is only compiled for test runs. #![allow(clippy::indexing_slicing)] -#![allow(dead_code)] // Can be removed once PR #3385 is merged use std::collections::BTreeMap; use std::ops::Deref; use std::panic; @@ -250,6 +249,7 @@ impl TestContext { Self::builder().configure_fiona().build().await } + #[allow(dead_code)] /// Print current chat state. pub async fn print_chats(&self) { println!("\n========== Chats of {}: ==========", self.name()); diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 000000000..9067f1962 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1 @@ +mod aeap; diff --git a/src/tests/aeap.rs b/src/tests/aeap.rs new file mode 100644 index 000000000..9badf9705 --- /dev/null +++ b/src/tests/aeap.rs @@ -0,0 +1,360 @@ +#![allow(clippy::indexing_slicing)] + +use anyhow::Result; + +use crate::chat; +use crate::chat::ChatId; +use crate::constants; +use crate::contact; +use crate::contact::Contact; +use crate::contact::ContactId; +use crate::message::Message; +use crate::peerstate; +use crate::peerstate::Peerstate; +use crate::receive_imf::receive_imf; +use crate::stock_str; +use crate::test_utils::TestContext; +use crate::test_utils::TestContextManager; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_change_primary_self_addr() -> Result<()> { + let mut tcm = TestContextManager::new().await; + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + tcm.send_recv_accept(&alice, &bob, "Hi").await; + let bob_alice_chat = bob.create_chat(&alice).await; + + tcm.change_addr(&alice, "alice@someotherdomain.xyz").await; + + tcm.section("Bob sends a message to Alice, encrypting to her previous key"); + let sent = bob.send_text(bob_alice_chat.id, "hi back").await; + + // Alice set up message forwarding so that she still receives + // the message with her new address + let alice_msg = alice.recv_msg(&sent).await; + assert_eq!(alice_msg.text, Some("hi back".to_string())); + assert_eq!(alice_msg.get_showpadlock(), true); + let alice_bob_chat = alice.create_chat(&bob).await; + assert_eq!(alice_msg.chat_id, alice_bob_chat.id); + + tcm.section("Bob sends a message to Alice without In-Reply-To"); + // Even if Bob sends a message to Alice without In-Reply-To, + // it's still assigned to the 1:1 chat with Bob and not to + // a group (without secondary addresses, an ad-hoc group + // would be created) + receive_imf( + &alice, + b"From: bob@example.net +To: alice@example.org +Chat-Version: 1.0 +Message-ID: <456@example.com> + +Message w/out In-Reply-To +", + false, + ) + .await?; + + let alice_msg = alice.get_last_msg().await; + + assert_eq!( + alice_msg.text, + Some("Message w/out In-Reply-To".to_string()) + ); + assert_eq!(alice_msg.get_showpadlock(), false); + assert_eq!(alice_msg.chat_id, alice_bob_chat.id); + + Ok(()) +} + +enum ChatForTransition { + OneToOne, + GroupChat, + VerifiedGroup, +} +use ChatForTransition::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_0() { + check_aeap_transition(OneToOne, false, false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_1() { + check_aeap_transition(GroupChat, false, false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_0_verified() { + check_aeap_transition(OneToOne, true, false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_1_verified() { + check_aeap_transition(GroupChat, true, false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_2_verified() { + check_aeap_transition(VerifiedGroup, true, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_0_bob_knew_new_addr() { + check_aeap_transition(OneToOne, false, true).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_1_bob_knew_new_addr() { + check_aeap_transition(GroupChat, false, true).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_0_verified_bob_knew_new_addr() { + check_aeap_transition(OneToOne, true, true).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_1_verified_bob_knew_new_addr() { + check_aeap_transition(GroupChat, true, true).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_transition_2_verified_bob_knew_new_addr() { + check_aeap_transition(VerifiedGroup, true, true).await; +} + +/// Happy path test for AEAP in various configurations. +/// - `chat_for_transition`: Which chat the transition message should be sent in +/// - `verified`: Whether Alice and Bob verified each other +/// - `bob_knew_new_addr`: Whether Bob already had a chat with Alice's new address +async fn check_aeap_transition( + chat_for_transition: ChatForTransition, + verified: bool, + bob_knew_new_addr: bool, +) { + // Alice's new address is "fiona@example.net" so that we can test + // the case where Bob already had contact with Alice's new address + const ALICE_NEW_ADDR: &str = "fiona@example.net"; + + let mut tcm = TestContextManager::new().await; + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + if bob_knew_new_addr { + let fiona = tcm.fiona().await; + + tcm.send_recv_accept(&fiona, &bob, "Hi").await; + tcm.send_recv_accept(&bob, &fiona, "Hi back").await; + } + + tcm.send_recv_accept(&alice, &bob, "Hi").await; + tcm.send_recv_accept(&bob, &alice, "Hi back").await; + + if verified { + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + } + + let mut groups = vec![ + chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 0") + .await + .unwrap(), + chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 1") + .await + .unwrap(), + ]; + if verified { + groups.push( + chat::create_group_chat(&bob, chat::ProtectionStatus::Protected, "Group 2") + .await + .unwrap(), + ); + groups.push( + chat::create_group_chat(&bob, chat::ProtectionStatus::Protected, "Group 3") + .await + .unwrap(), + ); + } + + let old_contact = Contact::create(&bob, "Alice", "alice@example.org") + .await + .unwrap(); + for group in &groups { + chat::add_contact_to_chat(&bob, *group, old_contact) + .await + .unwrap(); + } + + // Already add the new contact to one of the groups. + // We can then later check that the contact isn't in the group twice. + let already_new_contact = Contact::create(&bob, "Alice", "fiona@example.net") + .await + .unwrap(); + chat::add_contact_to_chat(&bob, groups[0], already_new_contact) + .await + .unwrap(); + + // groups 0 and 2 stay unpromoted (i.e. local + // on Bob's device, Alice doesn't know about them) + tcm.section("Promoting group 1"); + let sent = bob.send_text(groups[1], "group created").await; + let group1_alice = alice.recv_msg(&sent).await.chat_id; + + let mut group3_alice = None; + if verified { + tcm.section("Promoting group 3"); + let sent = bob.send_text(groups[3], "group created").await; + group3_alice = Some(alice.recv_msg(&sent).await.chat_id); + } + + tcm.change_addr(&alice, ALICE_NEW_ADDR).await; + + tcm.section("Alice sends another message to Bob, this time from her new addr"); + // No matter which chat Alice sends to, the transition should be done in all groups + let chat_to_send = match chat_for_transition { + OneToOne => alice.create_chat(&bob).await.id, + GroupChat => group1_alice, + VerifiedGroup => group3_alice.expect("No verified group"), + }; + let sent = alice + .send_text(chat_to_send, "Hello from my new addr!") + .await; + let recvd = bob.recv_msg(&sent).await; + let sent_timestamp = recvd.timestamp_sent; + assert_eq!(recvd.text.unwrap(), "Hello from my new addr!"); + + tcm.section("Check that the AEAP transition worked"); + check_that_transition_worked( + &groups, + &alice, + "alice@example.org", + ALICE_NEW_ADDR, + "Alice", + &bob, + ) + .await; + + // Assert that the autocrypt header is also applied to the peerstate + // if the address changed + let bob_alice_peerstate = Peerstate::from_addr(&bob, ALICE_NEW_ADDR) + .await + .unwrap() + .unwrap(); + assert_eq!(bob_alice_peerstate.last_seen, sent_timestamp); + assert_eq!(bob_alice_peerstate.last_seen_autocrypt, sent_timestamp); + + tcm.section("Test switching back"); + tcm.change_addr(&alice, "alice@example.org").await; + let sent = alice + .send_text(chat_to_send, "Hello from my old addr!") + .await; + let recvd = bob.recv_msg(&sent).await; + assert_eq!(recvd.text.unwrap(), "Hello from my old addr!"); + + check_that_transition_worked( + &groups, + &alice, + // Note that "alice@example.org" and ALICE_NEW_ADDR are switched now: + ALICE_NEW_ADDR, + "alice@example.org", + "Alice", + &bob, + ) + .await; +} + +async fn check_that_transition_worked( + groups: &[ChatId], + alice: &TestContext, + old_alice_addr: &str, + new_alice_addr: &str, + name: &str, + bob: &TestContext, +) { + let new_contact = Contact::lookup_id_by_addr(bob, new_alice_addr, contact::Origin::Unknown) + .await + .unwrap() + .unwrap(); + + for group in groups { + let members = chat::get_chat_contacts(bob, *group).await.unwrap(); + // In all the groups, exactly Bob and Alice's new number are members. + // (and Alice's new number isn't in there twice) + assert_eq!(members.len(), 2); + assert!(members.contains(&new_contact)); + assert!(members.contains(&ContactId::SELF)); + + let info_msg = get_last_info_msg(bob, *group).await; + let expected_text = + stock_str::aeap_addr_changed(bob, name, old_alice_addr, new_alice_addr).await; + assert_eq!(info_msg.text.unwrap(), expected_text); + assert_eq!(info_msg.from_id, ContactId::INFO); + + let msg = format!("Sending to group {}", group); + let sent = bob.send_text(*group, &msg).await; + let recvd = alice.recv_msg(&sent).await; + assert_eq!(recvd.text.unwrap(), msg); + } +} + +async fn mark_as_verified(this: &TestContext, other: &TestContext) { + let other_addr = other.get_primary_self_addr().await.unwrap(); + let mut peerstate = peerstate::Peerstate::from_addr(this, &other_addr) + .await + .unwrap() + .unwrap(); + + peerstate.verified_key = peerstate.public_key.clone(); + peerstate.verified_key_fingerprint = peerstate.public_key_fingerprint.clone(); + peerstate.to_save = Some(peerstate::ToSave::All); + + peerstate.save_to_db(&this.sql, false).await.unwrap(); +} + +async fn get_last_info_msg(t: &TestContext, chat_id: ChatId) -> Message { + let msgs = chat::get_chat_msgs(&t.ctx, chat_id, constants::DC_GCM_INFO_ONLY) + .await + .unwrap(); + let msg_id = if let chat::ChatItem::Message { msg_id } = msgs.last().unwrap() { + msg_id + } else { + panic!("Wrong item type"); + }; + Message::load_from_db(&t.ctx, *msg_id).await.unwrap() +} + +/// Test that an attacker - here Fiona - can't replay a message sent by Alice +/// to make Bob think that there was a transition to Fiona's address. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_aeap_replay_attack() -> Result<()> { + let mut tcm = TestContextManager::new().await; + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + tcm.send_recv_accept(&alice, &bob, "Hi").await; + tcm.send_recv_accept(&bob, &alice, "Hi back").await; + + let group = + chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 0").await?; + + let bob_alice_contact = Contact::create(&bob, "Alice", "alice@example.org").await?; + chat::add_contact_to_chat(&bob, group, bob_alice_contact).await?; + + // Alice sends a message which Bob doesn't receive or something + // A real attack would rather re-use a message that was sent to a group + // and replace the Message-Id or so. + let chat = alice.create_chat(&bob).await; + let sent = alice.send_text(chat.id, "whoop whoop").await; + + // Fiona gets the message, replaces the From addr... + let sent = sent + .payload() + .replace("From: ", "From: ") + .replace("addr=alice@example.org;", "addr=fiona@example.net;"); + sent.find("From: ").unwrap(); // Assert that it worked + sent.find("addr=fiona@example.net;").unwrap(); // Assert that it worked + + tcm.section("Fiona replaced the From addr and forwards the message to Bob"); + receive_imf(&bob, sent.as_bytes(), false).await?.unwrap(); + + // Check that no transition was done + assert!(chat::is_contact_in_chat(&bob, group, bob_alice_contact).await?); + let bob_fiona_contact = Contact::create(&bob, "", "fiona@example.net").await?; + assert!(!chat::is_contact_in_chat(&bob, group, bob_fiona_contact).await?); + + Ok(()) +}